mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-27 12:34:14 +02:00
Compare commits
124 Commits
ac40d110fb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d5027eadbb | |||
| da09206826 | |||
| 9812166071 | |||
| 4efe28ce03 | |||
| 7c4355338b | |||
| 4c3e86c07e | |||
| 44cabbcfe4 | |||
| a2a30ef7c4 | |||
| 0b4f480dfe | |||
| 05260de87b | |||
| db154a8d06 | |||
| 7dc1d345b0 | |||
| ba1811a0cf | |||
| a3dc8d3ad4 | |||
| b1fe1ef7a7 | |||
| 9584fc4b7c | |||
| 25baf48153 | |||
| 7f51a3e10a | |||
| 503823bfce | |||
| 30c97612a2 | |||
| 8cdf51405a | |||
| 4a5755e552 | |||
| ef8b3866ba | |||
| f6f53e3f52 | |||
| 5120feb417 | |||
| cd38f6f2a2 | |||
| 29fe731728 | |||
| b740508b7f | |||
| 76eea09192 | |||
| 30e83d8c52 | |||
| 3a50cd9b0e | |||
| acb770909e | |||
| a9ad6e2f34 | |||
| ff795258c4 | |||
| 0cb424bd06 | |||
| 47e5a8072b | |||
| 5bd55801e9 | |||
| 5ccb089cdd | |||
| 416ababc41 | |||
| 63771a41b5 | |||
| 57905a5789 | |||
| 451cccd615 | |||
| af28a49b72 | |||
| 926bbcf75f | |||
| 6bcdb87524 | |||
| f850d0bee8 | |||
| d7036ad807 | |||
| 09fb81b99a | |||
| 501379def2 | |||
| 52a831e1fa | |||
| c1bb3b33f4 | |||
| f91ec74dde | |||
| b08d0692b7 | |||
| 627b818a30 | |||
| 0d2befbc00 | |||
| db387525e2 | |||
| 6763103d57 | |||
| 852c32fc07 | |||
| e82548bee9 | |||
| 60ab39b0f6 | |||
| b57c37ca62 | |||
| 329831d986 | |||
| b80d6e7cd3 | |||
| 6d42556d9f | |||
| 2da101ca90 | |||
| 07501d604e | |||
| 73ad43bd8f | |||
| c1d9e60656 | |||
| 92cbc8fd2a | |||
| db56262924 | |||
| 86cb7e430e | |||
| 3d260edcdb | |||
| 53706a80d8 | |||
| 1e74bcd9de | |||
| 45aec7735b | |||
| 923367d2a8 | |||
| 6dd260a7b2 | |||
| 132c564aef | |||
| e9c4969af7 | |||
| 040c4b5e29 | |||
| 71d708e2da | |||
| b490720652 | |||
| 519f2245f6 | |||
| b61193f66f | |||
| e6291eb8e8 | |||
| 0ca4b9f6c3 | |||
| a08cdf7272 | |||
| 2fdb225936 | |||
| 95f3ea2f6d | |||
| 0e02190eb2 | |||
| 2807771200 | |||
| 4cffdf93b6 | |||
| 9738345b45 | |||
| 286002fe25 | |||
| ce0e7e6867 | |||
| 5f0b015186 | |||
| e5738c5b7d | |||
| c3c264cd78 | |||
| d392ab4b8f | |||
| ebd4a57563 | |||
| 23d83ec153 | |||
| d43c535cc9 | |||
| 71782aec20 | |||
| 59dc59cf2f | |||
| 469c8cbc70 | |||
| 1330dbb435 | |||
| e7f19de2da | |||
| 4c9a307cb9 | |||
| 4381c6caa3 | |||
| 07d7aedc26 | |||
| 5622308ba2 | |||
| 149ae961cc | |||
| 4f309770f0 | |||
| bb037ee3ff | |||
| d1256872c2 | |||
| 800189debb | |||
| 522e008c26 | |||
| c1cc9a5b98 | |||
| 4e754cfac1 | |||
| 151b16ba76 | |||
| 0902725a59 | |||
| 280051745a | |||
| e3ede85efd | |||
| 95c2f6cf6d |
@@ -25,6 +25,7 @@ message(" - find_spacemouse")
|
||||
# spnav Linux, through the spacenavd daemon (libspnav)
|
||||
# hid any platform, directly over USB (hidapi), no 3Dconnexion driver
|
||||
# auto spnav on Linux when libspnav is found, hid otherwise
|
||||
# On macOS the 3DxWare backend is added to whichever of these is chosen.
|
||||
# A backend whose library is not found downgrades the option to off with a
|
||||
# warning, rather than failing configure for an opt-in feature.
|
||||
set(QET_SPACEMOUSE_BACKEND "auto" CACHE STRING "3D mouse backend: auto, spnav or hid")
|
||||
@@ -33,6 +34,7 @@ set_property(CACHE QET_SPACEMOUSE_BACKEND PROPERTY STRINGS auto spnav hid)
|
||||
set(QET_SPACEMOUSE_ENABLED FALSE)
|
||||
set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED FALSE)
|
||||
set(QET_SPACEMOUSE_BACKEND_HID_ENABLED FALSE)
|
||||
set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED FALSE)
|
||||
|
||||
if(QET_ENABLE_SPACEMOUSE)
|
||||
find_package(PkgConfig)
|
||||
@@ -73,6 +75,16 @@ if(QET_ENABLE_SPACEMOUSE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# macOS: with 3DxWare installed the device can only be read through
|
||||
# 3DxWare, so its backend comes too, whichever backend was asked for.
|
||||
# It loads 3DxWare's library at run time and needs nothing to build.
|
||||
if(APPLE)
|
||||
set(QET_SPACEMOUSE_ENABLED TRUE)
|
||||
set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED TRUE)
|
||||
add_definitions(-DQET_SPACEMOUSE_BACKEND_CONNEXION)
|
||||
message("QET_ENABLE_SPACEMOUSE ON (backend: 3DxWare when installed)")
|
||||
endif()
|
||||
|
||||
if(QET_SPACEMOUSE_ENABLED)
|
||||
add_definitions(-DQET_SPACEMOUSE_SUPPORT)
|
||||
else()
|
||||
|
||||
@@ -168,6 +168,9 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/borderproperties.h
|
||||
${QET_DIR}/sources/bordertitleblock.cpp
|
||||
${QET_DIR}/sources/bordertitleblock.h
|
||||
${QET_DIR}/sources/bordercelllabels.h
|
||||
${QET_DIR}/sources/cellruler.cpp
|
||||
${QET_DIR}/sources/cellruler.h
|
||||
${QET_DIR}/sources/conductorautonumerotation.cpp
|
||||
${QET_DIR}/sources/conductorautonumerotation.h
|
||||
${QET_DIR}/sources/conductornumexport.cpp
|
||||
@@ -282,6 +285,14 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/recentfiles.h
|
||||
${QET_DIR}/sources/shortcutmanager.cpp
|
||||
${QET_DIR}/sources/shortcutmanager.h
|
||||
${QET_DIR}/sources/shortcutbarsettings.cpp
|
||||
${QET_DIR}/sources/shortcutbarsettings.h
|
||||
${QET_DIR}/sources/diagramcontexttoolbar.cpp
|
||||
${QET_DIR}/sources/diagramcontexttoolbar.h
|
||||
${QET_DIR}/sources/diagramgestureoverlay.cpp
|
||||
${QET_DIR}/sources/diagramgestureoverlay.h
|
||||
${QET_DIR}/sources/commandsearchpopup.cpp
|
||||
${QET_DIR}/sources/commandsearchpopup.h
|
||||
${QET_DIR}/sources/titleblockcell.cpp
|
||||
${QET_DIR}/sources/titleblockcell.h
|
||||
${QET_DIR}/sources/titleblockproperties.cpp
|
||||
@@ -448,6 +459,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.cpp
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementpickerpopup.cpp
|
||||
${QET_DIR}/sources/ElementsCollection/elementpickerpopup.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.cpp
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementslocation.cpp
|
||||
@@ -703,6 +716,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/ui/borderpropertieswidget.h
|
||||
${QET_DIR}/sources/ui/compositetexteditdialog.cpp
|
||||
${QET_DIR}/sources/ui/compositetexteditdialog.h
|
||||
${QET_DIR}/sources/ui/conductorpropertieseditorwidget.cpp
|
||||
${QET_DIR}/sources/ui/conductorpropertieseditorwidget.h
|
||||
${QET_DIR}/sources/ui/contactgroupselectiondialog.cpp
|
||||
${QET_DIR}/sources/ui/contactgroupselectiondialog.h
|
||||
${QET_DIR}/sources/ui/conductorpropertiesdialog.cpp
|
||||
@@ -794,6 +809,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/ui/configpage/guidespropertieswidget.h
|
||||
${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.cpp
|
||||
${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h
|
||||
${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.cpp
|
||||
${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h
|
||||
|
||||
${QET_DIR}/sources/undocommand/addelementtextcommand.cpp
|
||||
${QET_DIR}/sources/undocommand/addelementtextcommand.h
|
||||
@@ -819,6 +836,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/undocommand/removediagramcommand.h
|
||||
${QET_DIR}/sources/undocommand/setautonumcontextcommand.cpp
|
||||
${QET_DIR}/sources/undocommand/setautonumcontextcommand.h
|
||||
${QET_DIR}/sources/undocommand/alignselectioncommand.cpp
|
||||
${QET_DIR}/sources/undocommand/alignselectioncommand.h
|
||||
${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp
|
||||
${QET_DIR}/sources/undocommand/rotateselectioncommand.h
|
||||
${QET_DIR}/sources/undocommand/promoteshapecommand.cpp
|
||||
@@ -905,6 +924,13 @@ if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
|
||||
)
|
||||
endif()
|
||||
|
||||
if(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED)
|
||||
list(APPEND QET_SRC_FILES
|
||||
${QET_DIR}/sources/spacemouse/connexionbackend.cpp
|
||||
${QET_DIR}/sources/spacemouse/connexionbackend.h
|
||||
)
|
||||
endif()
|
||||
|
||||
set(TS_FILES
|
||||
${QET_DIR}/lang/qet_ar.ts
|
||||
${QET_DIR}/lang/qet_ca.ts
|
||||
|
||||
@@ -912,6 +912,31 @@ Note: these options DO NOT allow or block auto numberings, only their update pol
|
||||
<translation>Edit the properties of several conductors</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>ConductorPropertiesEditorWidget</name>
|
||||
<message>
|
||||
<location filename="../sources/ui/conductorpropertieseditorwidget.cpp" line="65"/>
|
||||
<source>Appliquer les propriétés à l'ensemble des conducteurs de ce potentiel</source>
|
||||
<translation>Apply properties to all conductors of this potential</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/ui/conductorpropertieseditorwidget.cpp" line="248"/>
|
||||
<source>Modifier les propriétés d'un conducteur</source>
|
||||
<comment>undo caption</comment>
|
||||
<translation>Edit conductor properties</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/ui/conductorpropertieseditorwidget.cpp" line="258"/>
|
||||
<source>Modifier les propriétés de plusieurs conducteurs</source>
|
||||
<comment>undo caption</comment>
|
||||
<translation>Edit the properties of several conductors</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/ui/conductorpropertieseditorwidget.cpp" line="278"/>
|
||||
<source>Conducteur</source>
|
||||
<translation>Conductor</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>ConductorPropertiesWidget</name>
|
||||
<message>
|
||||
|
||||
+1
-1
@@ -62,6 +62,6 @@
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.3.0</string>
|
||||
<string>14.0.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -99,6 +99,7 @@ fi
|
||||
|
||||
cmake -S . -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \
|
||||
-DQT_VERSION_MAJOR=$QT_MAJOR \
|
||||
-DBUILD_WITH_KF=$BUILD_WITH_KF \
|
||||
-DBUILD_KF=OFF \
|
||||
@@ -258,6 +259,7 @@ echo "Install Info.plist and app icon:"
|
||||
cp -R ${current_dir}/misc/Info.plist $BUNDLE/Contents/
|
||||
cp -R ${current_dir}/ico/mac_icon/*.icns $BUNDLE/Contents/Resources/
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION r$HEAD" "$BUNDLE/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion 14.0.0" "$BUNDLE/Contents/Info.plist"
|
||||
|
||||
### add missing files ###############################################
|
||||
echo
|
||||
@@ -398,10 +400,12 @@ done
|
||||
|
||||
echo "-- Signing main executable..."
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
|
||||
"$BUNDLE/Contents/MacOS/$APPNAME"
|
||||
|
||||
echo "-- Signing bundle..."
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$BUNDLE"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" "$BUNDLE"
|
||||
|
||||
echo
|
||||
echo "Verifying bundle signature..."
|
||||
@@ -502,8 +506,10 @@ find "$MOUNT_POINT/$BUNDLE/Contents/PlugIns" \( -name "*.dylib" -o -name "*.so"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$lib"
|
||||
done
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
|
||||
"$MOUNT_POINT/$BUNDLE/Contents/MacOS/$APPNAME"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
|
||||
"$MOUNT_POINT/$BUNDLE"
|
||||
|
||||
echo "Verifying bundle signature inside DMG..."
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- 3D mouse with 3DxWare installed: lets QElectroTech load
|
||||
3Dconnexion's client library, which 3Dconnexion signs, at run time
|
||||
(sources/spacemouse/connexionbackend.h). The hardened runtime
|
||||
refuses it otherwise, and the 3D mouse does nothing. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+13
-5
@@ -271,6 +271,13 @@ Python, plus the hang guard on `addConductor` and the database refresh in
|
||||
QElectroTech applies to its own custom-query box, not one invented here.
|
||||
An empty result and a failed query are told apart: `row_count` 0 with no
|
||||
`error` means nothing matched, and a typo'd column name says so.
|
||||
- **Texts, shapes and images are in the database too**, one row each in
|
||||
`drawing_item_view` (`uuid`, `kind`, `folio`, position and size, and a
|
||||
`description`: the shape type or the text). The uuid is the one saved in
|
||||
the file, and every `qet_edit` op that takes a text, shape or image
|
||||
`index` also takes that uuid, which does not shift the way an index
|
||||
does. `qet_element_build` gives every part of a symbol a uuid as well,
|
||||
returned in `part_uuids`; `qet_element_info` lists them in `part_list`.
|
||||
- **`qet_export` isolates its launch.** SingleApplication keys its socket
|
||||
on `applicationFilePath()`, so a second launch of the same binary path
|
||||
forwards its request to an already-running instance and returns *that*
|
||||
@@ -295,11 +302,12 @@ Python, plus the hang guard on `addConductor` and the database refresh in
|
||||
no-op edit. Where an element predates persisted uuids the end cannot be
|
||||
resolved and keeps a `#`-marked unstable key; the diff then reports
|
||||
`unstable_keys` and says so rather than pretending to be comparable.
|
||||
- **Texts, shapes and images have no uuid**, so `qet_diff` cannot say "the same
|
||||
text, edited": an edited text reads as the old one removed and a new one
|
||||
added, both shown. Shapes and images are keyed by position, so a restyle
|
||||
or rescale *is* reported as a change to that item, but a move reads as a
|
||||
removal plus an addition. The folio `version` attribute is left out of the
|
||||
- **Texts, shapes and images are keyed by uuid** when every one on both sides
|
||||
has one, so an edit or a move reads as a change to that item. A file saved
|
||||
before they carried a uuid has none; for such a pair (including a legacy
|
||||
file against its first re-save) that kind falls back to position, where an
|
||||
edited text reads as removed plus added and a move as a removal plus an
|
||||
addition. Each section says which it used in `keyed_by`. The folio `version` attribute is left out of the
|
||||
comparison on purpose: QElectroTech rewrites it on every save, and
|
||||
including it made every folio of any re-saved project look edited.
|
||||
- **Elements** written before persisted uuids fall back to a positional key,
|
||||
|
||||
+137
-53
@@ -314,32 +314,42 @@ def _plain_text(html: str) -> str:
|
||||
def _extras(root: ET.Element) -> dict:
|
||||
"""Everything a folio holds besides elements and conductors.
|
||||
|
||||
Independent texts, shapes and images have no uuid, so nothing here can
|
||||
say "this is the same text, edited". A change to one therefore reads as
|
||||
the old one removed and a new one added, with both shown -- accurate,
|
||||
if less tidy than a move. Position is the identity they have.
|
||||
Independent texts, shapes and images are records, not a keyed dict:
|
||||
files written since they carry a uuid are compared by it (see
|
||||
_diff_items), so an edited or moved text reads as that text, changed.
|
||||
Older files have only position to go on, and there a change reads as
|
||||
the old one removed and a new one added, with both shown.
|
||||
"""
|
||||
folios, texts, shapes, images = {}, {}, {}, {}
|
||||
folios, texts, shapes, images = {}, [], [], []
|
||||
|
||||
def record(el, label, value):
|
||||
return {"uuid": el.get("uuid", ""), "key": tuple(label.values()),
|
||||
"label": label, "value": value}
|
||||
|
||||
for n, d in _folios(root):
|
||||
folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS}
|
||||
for t in d.iter("input"):
|
||||
key = (n, t.get("x", ""), t.get("y", ""), _plain_text(t.get("text", "")))
|
||||
texts[key] = {"rotation": t.get("rotation", "0"),
|
||||
"font": t.get("font", ""), "color": t.get("color", "")}
|
||||
texts.append(record(
|
||||
t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""),
|
||||
"text": _plain_text(t.get("text", ""))},
|
||||
{"rotation": t.get("rotation", "0"),
|
||||
"font": t.get("font", ""), "color": t.get("color", "")}))
|
||||
for sh in d.iter("shape"):
|
||||
pen, brush = sh.find("pen"), sh.find("brush")
|
||||
key = (n, sh.get("type", ""), sh.get("x1", ""), sh.get("y1", ""),
|
||||
sh.get("x2", ""), sh.get("y2", ""))
|
||||
shapes[key] = {
|
||||
"line_color": pen.get("color", "") if pen is not None else "",
|
||||
"line_style": pen.get("style", "") if pen is not None else "",
|
||||
"line_width": pen.get("widthF", "") if pen is not None else "",
|
||||
"fill": (brush.get("color", "") if brush is not None and
|
||||
brush.get("style", "") != "NoBrush" else "none"),
|
||||
"rotation": sh.get("rotation", "0")}
|
||||
shapes.append(record(
|
||||
sh, {"folio": n, "type": sh.get("type", ""),
|
||||
"from": [sh.get("x1", ""), sh.get("y1", "")],
|
||||
"to": [sh.get("x2", ""), sh.get("y2", "")]},
|
||||
{"line_color": pen.get("color", "") if pen is not None else "",
|
||||
"line_style": pen.get("style", "") if pen is not None else "",
|
||||
"line_width": pen.get("widthF", "") if pen is not None else "",
|
||||
"fill": (brush.get("color", "") if brush is not None and
|
||||
brush.get("style", "") != "NoBrush" else "none"),
|
||||
"rotation": sh.get("rotation", "0")}))
|
||||
for im in d.iter("image"):
|
||||
key = (n, im.get("x", ""), im.get("y", ""))
|
||||
images[key] = {"scale": im.get("size", ""), "rotation": im.get("rotation", "")}
|
||||
images.append(record(
|
||||
im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")},
|
||||
{"scale": im.get("size", ""), "rotation": im.get("rotation", "")}))
|
||||
|
||||
element_texts = {}
|
||||
for n, d in _folios(root):
|
||||
@@ -388,6 +398,32 @@ def _diff_keyed(a: dict, b: dict, label) -> dict:
|
||||
"changed": changed[:50]}
|
||||
|
||||
|
||||
def _diff_items(a: list, b: list) -> dict:
|
||||
"""_diff_keyed() over _extras() records of one kind.
|
||||
|
||||
Keyed on uuid only when every item on both sides has one. A file saved
|
||||
before these items carried a uuid has none, and the first save by a
|
||||
current QElectroTech gives them one, so a mixed pair falls back to
|
||||
position for the whole kind rather than reading as everything removed
|
||||
and re-added. On uuid, position is part of what is compared, so a move
|
||||
is a change to that item.
|
||||
"""
|
||||
by_uuid = all(r["uuid"] for r in a + b)
|
||||
|
||||
def key(r):
|
||||
return r["uuid"] if by_uuid else str(r["key"])
|
||||
|
||||
def keyed(rs):
|
||||
return {key(r): ({**r["label"], **r["value"]} if by_uuid else r["value"])
|
||||
for r in rs}
|
||||
|
||||
labels = {key(r): ({**r["label"], "uuid": r["uuid"]} if by_uuid else r["label"])
|
||||
for r in a + b}
|
||||
out = _diff_keyed(keyed(a), keyed(b), lambda k: labels[k])
|
||||
out["keyed_by"] = "uuid" if by_uuid else "position"
|
||||
return out
|
||||
|
||||
|
||||
def _diff_extras(before: ET.Element, after: ET.Element) -> dict:
|
||||
a, b = _extras(before), _extras(after)
|
||||
out = {}
|
||||
@@ -407,13 +443,9 @@ def _diff_extras(before: ET.Element, after: ET.Element) -> dict:
|
||||
if len(a["folios"]) != len(b["folios"]) and folio_changes:
|
||||
out["folios"]["note"] = ("the folio count changed, so changes listed here may be "
|
||||
"later folios shifting position rather than edits")
|
||||
out["texts"] = _diff_keyed(a["texts"], b["texts"],
|
||||
lambda k: {"folio": k[0], "x": k[1], "y": k[2], "text": k[3]})
|
||||
out["shapes"] = _diff_keyed(a["shapes"], b["shapes"],
|
||||
lambda k: {"folio": k[0], "type": k[1],
|
||||
"from": [k[2], k[3]], "to": [k[4], k[5]]})
|
||||
out["images"] = _diff_keyed(a["images"], b["images"],
|
||||
lambda k: {"folio": k[0], "x": k[1], "y": k[2]})
|
||||
out["texts"] = _diff_items(a["texts"], b["texts"])
|
||||
out["shapes"] = _diff_items(a["shapes"], b["shapes"])
|
||||
out["images"] = _diff_items(a["images"], b["images"])
|
||||
# Keyed by element, what the field is bound to, and the nth such field.
|
||||
# A field's own text is also compared ("shows"), so relabelling an
|
||||
# element shows up here as well as in the element's information.
|
||||
@@ -594,14 +626,20 @@ def tool_element_info(path: str) -> dict:
|
||||
ordered, ambiguous = _terminals_in_index_order(list(root.iter("terminal")))
|
||||
terminals = [{"index": i, "x": t.get("x"), "y": t.get("y"),
|
||||
"orientation": t.get("orientation"),
|
||||
"name": t.get("name", ""), "type": t.get("type", "")}
|
||||
"name": t.get("name", ""), "type": t.get("type", ""),
|
||||
"uuid": t.get("uuid", "")}
|
||||
for i, t in enumerate(ordered)]
|
||||
info_fields = sorted({(i.text or "").strip()
|
||||
for i in root.iter("info_name") if (i.text or "").strip()})
|
||||
parts = {}
|
||||
part_list = []
|
||||
desc = root.find("description")
|
||||
for child in (desc if desc is not None else []):
|
||||
parts[child.tag] = parts.get(child.tag, 0) + 1
|
||||
if child.tag != "terminal":
|
||||
# uuid is empty for a part saved before parts carried one; the
|
||||
# element editor gives it one on the next save.
|
||||
part_list.append({"type": child.tag, "uuid": child.get("uuid", "")})
|
||||
return {
|
||||
"file": str(Path(path).expanduser()),
|
||||
"type": root.get("type", ""), "link_type": root.get("link_type", ""),
|
||||
@@ -614,6 +652,7 @@ def tool_element_info(path: str) -> dict:
|
||||
"index is undefined" if ambiguous else ""),
|
||||
"info_fields": info_fields,
|
||||
"parts": parts,
|
||||
"part_list": part_list,
|
||||
}
|
||||
|
||||
|
||||
@@ -851,11 +890,15 @@ def _validate_part(index: int, part) -> str:
|
||||
for key in spec["required"]:
|
||||
if key not in part:
|
||||
raise ValueError(f"part {index} ({kind}) is missing {key!r}")
|
||||
allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias"}
|
||||
allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias", "uuid"}
|
||||
for key in part:
|
||||
if key not in allowed:
|
||||
raise ValueError(f"part {index} ({kind}): unexpected {key!r}; "
|
||||
f"allowed: {', '.join(sorted(allowed))}")
|
||||
if "uuid" in part and not (isinstance(part["uuid"], str)
|
||||
and _UUID_RE.fullmatch(part["uuid"])):
|
||||
raise ValueError(f"part {index} ({kind}): uuid must look like "
|
||||
f"{{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}}, got {part['uuid']!r}")
|
||||
if kind == "polygon":
|
||||
pts = part["points"]
|
||||
if not isinstance(pts, list) or len(pts) < 2:
|
||||
@@ -866,9 +909,19 @@ def _validate_part(index: int, part) -> str:
|
||||
return kind
|
||||
|
||||
|
||||
def _part_element(part: dict) -> ET.Element:
|
||||
def _part_uuid(part: dict) -> str:
|
||||
"""The caller's uuid for this part, braced as QElectroTech writes it, or
|
||||
a new one. Every part carries one, as the element editor saves them."""
|
||||
given = part.get("uuid")
|
||||
if given:
|
||||
return given if given.startswith("{") else "{" + given + "}"
|
||||
return "{" + str(__import__("uuid").uuid4()) + "}"
|
||||
|
||||
|
||||
def _part_element(part: dict, uuid: str) -> ET.Element:
|
||||
kind = part["type"]
|
||||
node = ET.Element(kind)
|
||||
node.set("uuid", uuid)
|
||||
if kind == "polygon":
|
||||
for n, (px, py) in enumerate(part["points"], start=1):
|
||||
node.set(f"x{n}", _fmt(px))
|
||||
@@ -960,8 +1013,11 @@ def tool_element_build(output: str, names: dict, parts: list,
|
||||
ET.SubElement(kind, "kindInformation", {"name": key}).text = str(informations[key])
|
||||
ET.SubElement(root, "informations")
|
||||
description = ET.SubElement(root, "description")
|
||||
for part in parts:
|
||||
description.append(_part_element(part))
|
||||
part_uuids = [_part_uuid(part) for part in parts]
|
||||
if len(set(part_uuids)) != len(part_uuids):
|
||||
raise ValueError("two parts were given the same uuid")
|
||||
for part, part_uuid in zip(parts, part_uuids):
|
||||
description.append(_part_element(part, part_uuid))
|
||||
for t in terminals:
|
||||
attrs = {"x": _fmt(t["x"]), "y": _fmt(t["y"]),
|
||||
"orientation": t["orientation"],
|
||||
@@ -985,6 +1041,8 @@ def tool_element_build(output: str, names: dict, parts: list,
|
||||
# caller listed them in.
|
||||
"terminal_index_order": [t["name"] or f"({t['x']},{t['y']})"
|
||||
for t in check["terminals"]],
|
||||
# In the order the parts were given.
|
||||
"part_uuids": part_uuids,
|
||||
"verified": check}
|
||||
|
||||
|
||||
@@ -1034,8 +1092,9 @@ OPS = {
|
||||
("value", "str")]),
|
||||
"remove_plc_io": ("removePlcIO", [("folio", "folio"), ("element", "elmt"),
|
||||
("index", "folio")]),
|
||||
# Texts and shapes have no uuid; they are addressed by index into a
|
||||
# position-sorted listing, and add_text/add_shape return that index so
|
||||
# Texts, shapes and images are addressed by "index": their index in a
|
||||
# position-sorted listing, which add_text/add_shape return, or their
|
||||
# uuid, which does not shift when another one is added. So
|
||||
# it can be named as "$id". Indexes shift when one is added or deleted.
|
||||
"delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"),
|
||||
("terminal", "num")]),
|
||||
@@ -1091,38 +1150,38 @@ OPS = {
|
||||
("to_folio", "folio"), ("x", "num"), ("y", "num")]),
|
||||
"add_text": ("addText", [("folio", "folio"), ("text", "str"),
|
||||
("x", "num"), ("y", "num")]),
|
||||
"set_text": ("setTextContent", [("folio", "folio"), ("index", "folio"),
|
||||
"set_text": ("setTextContent", [("folio", "folio"), ("index", "text"),
|
||||
("text", "str")]),
|
||||
"set_text_color": ("setTextColor", [("folio", "folio"), ("index", "folio"),
|
||||
"set_text_color": ("setTextColor", [("folio", "folio"), ("index", "text"),
|
||||
("color", "str")]),
|
||||
"rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "folio"),
|
||||
"rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "text"),
|
||||
("angle", "num")]),
|
||||
"delete_text": ("deleteText", [("folio", "folio"), ("index", "folio")]),
|
||||
"delete_text": ("deleteText", [("folio", "folio"), ("index", "text")]),
|
||||
"add_shape": ("addShape", [("folio", "folio"), ("shape", "str"),
|
||||
("x1", "num"), ("y1", "num"),
|
||||
("x2", "num"), ("y2", "num")]),
|
||||
"set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "shape"),
|
||||
("property", "str"), ("value", "str")]),
|
||||
"add_image": ("addImage", [("folio", "folio"), ("file", "str"),
|
||||
("x", "num"), ("y", "num")]),
|
||||
"add_pdf_page": ("addPdfPage", [("folio", "folio"), ("file", "str"),
|
||||
("page", "num"), ("dpi", "num"),
|
||||
("x", "num"), ("y", "num")]),
|
||||
"scale_image": ("setImageScale", [("folio", "folio"), ("index", "folio"),
|
||||
"scale_image": ("setImageScale", [("folio", "folio"), ("index", "image"),
|
||||
("factor", "num")]),
|
||||
"rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "folio"),
|
||||
"rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "image"),
|
||||
("angle", "num")]),
|
||||
"delete_image": ("deleteImage", [("folio", "folio"), ("index", "folio")]),
|
||||
"delete_shape": ("deleteShape", [("folio", "folio"), ("index", "folio")]),
|
||||
"delete_image": ("deleteImage", [("folio", "folio"), ("index", "image")]),
|
||||
"delete_shape": ("deleteShape", [("folio", "folio"), ("index", "shape")]),
|
||||
"add_polygon": ("addPolygon", [("folio", "folio"), ("points", "points"),
|
||||
("closed", "bool")]),
|
||||
"set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "shape"),
|
||||
("points", "points")]),
|
||||
"add_path": ("addPath", [("folio", "folio"), ("nodes", "nodes"),
|
||||
("closed", "bool")]),
|
||||
"set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "shape"),
|
||||
("nodes", "nodes")]),
|
||||
"set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "shape"),
|
||||
("closed", "bool")]),
|
||||
"add_table": ("addTable", [("folio", "folio"), ("kind", "str"),
|
||||
("name", "str"), ("query", "str")]),
|
||||
@@ -1161,6 +1220,9 @@ _REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} |
|
||||
|
||||
_MARKER = "QETEDIT "
|
||||
|
||||
_UUID_RE = re.compile(r"\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}?")
|
||||
|
||||
|
||||
def _js(value) -> str:
|
||||
"""A JSON literal is a JavaScript literal for every type used here."""
|
||||
@@ -1175,11 +1237,15 @@ def _build_script(operations: list, output: str) -> str:
|
||||
JavaScript exception.
|
||||
"""
|
||||
refs: set[str] = set()
|
||||
# Resolvers a uuid reference needs; required only when one is used, so
|
||||
# an index-only edit still runs on a build that predates them.
|
||||
uuid_methods: set[str] = set()
|
||||
folio_js = "0"
|
||||
lines = [
|
||||
"// generated by qet-mcp; do not edit",
|
||||
"var R = {};", # $name -> value from an earlier op
|
||||
"var missing = [];",
|
||||
f"var need = {_js(_REQUIRED_METHODS)};",
|
||||
"var need = @NEED@;",
|
||||
"for (var i = 0; i < need.length; i++) {",
|
||||
" if (typeof qet[need[i]] !== 'function') missing.push(need[i]);",
|
||||
"}",
|
||||
@@ -1228,6 +1294,18 @@ def _build_script(operations: list, output: str) -> str:
|
||||
raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of "
|
||||
f"integer indices, got {value!r}")
|
||||
return _js(value)
|
||||
if kind in ("text", "shape", "image"):
|
||||
# A uuid names the item for good; it is turned into the index
|
||||
# the call takes at run time, by the item's own folio.
|
||||
if isinstance(value, str) and _UUID_RE.fullmatch(value):
|
||||
method = {"text": "textIndex", "shape": "shapeIndex",
|
||||
"image": "imageIndex"}[kind]
|
||||
uuid_methods.add(method)
|
||||
return f"qet.{method}({folio_js}, {_js(value)})"
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"operation {op_index}: {key!r} must be a {kind} index, "
|
||||
f"its uuid, or a \"$name\" reference, got {value!r}")
|
||||
return _js(value)
|
||||
if kind == "folio":
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"operation {op_index}: {key!r} must be a folio index "
|
||||
@@ -1314,6 +1392,7 @@ def _build_script(operations: list, output: str) -> str:
|
||||
for key, kind in spec:
|
||||
if key not in op:
|
||||
raise ValueError(f"operation {i} ({name}) is missing {key!r}")
|
||||
folio_js = args[0] if args else "0"
|
||||
args.append(ref_or(op[key], kind, i, key))
|
||||
|
||||
ident = op.get("id")
|
||||
@@ -1359,7 +1438,8 @@ def _build_script(operations: list, output: str) -> str:
|
||||
lines.append(f" qet.log({_js(_MARKER)} + JSON.stringify("
|
||||
"{kind: 'save', result: saved, stopped_early: stop}));")
|
||||
lines.append("}")
|
||||
return "\n".join(lines) + "\n"
|
||||
need = _js(sorted(set(_REQUIRED_METHODS) | uuid_methods))
|
||||
return "\n".join(line.replace("@NEED@", need) for line in lines) + "\n"
|
||||
|
||||
|
||||
def _parse_script_output(text: str) -> dict:
|
||||
@@ -2268,10 +2348,12 @@ TOOLS = [
|
||||
"that element; set_element_text takes " +
|
||||
", ".join(ELEMENT_TEXT_PROPERTIES) + ". A field bound with source "
|
||||
"\"info\" follows set_label/set_info. Indexes shift on delete. "
|
||||
"Texts and shapes have no uuid: add_text/add_shape return an "
|
||||
"index you can name as \"$id\", and the other text/shape ops "
|
||||
"take it as \"index\". Indexes shift when one is added or "
|
||||
"deleted. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) +
|
||||
"Texts, shapes and images: add_text/add_shape/add_image return an "
|
||||
"index you can name as \"$id\", and the other text/shape/image "
|
||||
"ops take it as \"index\". Indexes shift when one is added or "
|
||||
"deleted; \"index\" also accepts the item's uuid (from the "
|
||||
"project database's drawing_item_view, or a saved file), which "
|
||||
"does not. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) +
|
||||
" (fill accepts a colour or \"none\"). "
|
||||
"add_shape's own \"polygon\" is always the degenerate two-point "
|
||||
"form (it shares add_shape's p1/p2 shape); add_polygon takes as "
|
||||
@@ -2291,7 +2373,7 @@ TOOLS = [
|
||||
"shapes are listed by current on-folio position, so changing one "
|
||||
"shape's points can reorder it relative to the others -- re-list "
|
||||
"before addressing one by index again if more than one is being "
|
||||
"edited in the same run. "
|
||||
"edited in the same run, or address it by uuid. "
|
||||
"Tables: add_table places a BOM/nomenclature or summary table "
|
||||
"(kind is \"nomenclature\" or \"summary\") built from a query "
|
||||
"against a project database view -- run qet.query() (the "
|
||||
@@ -2512,7 +2594,9 @@ TOOLS = [
|
||||
'line x1,y1,x2,y2; rect/ellipse/arc x,y,width,height '
|
||||
"(arc also start,angle); circle x,y,diameter; polygon "
|
||||
'points:[[x,y],...] and closed; text x,y,text with optional '
|
||||
"size, rotation, color. Any part may carry style and antialias. "
|
||||
"size, rotation, color. Any part may carry style and antialias, "
|
||||
"and a uuid to name it by; parts without one get a new uuid, "
|
||||
"returned in part_uuids. "
|
||||
"Coordinates are the element's own, with (0,0) at its origin.",
|
||||
"items": {"type": "object"},
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ needs_examples = unittest.skipUnless(have_binary and have_examples,
|
||||
COIL = "common://10_electric/10_allpole/310_relays_contactors_contacts/01_coils/bobine_ka_a_remanence.elmt"
|
||||
SLAVE = ("common://10_electric/10_allpole/310_relays_contactors_contacts/"
|
||||
"02_contacts_cross_referencing/15_protection_contacts/contact_relais_nf_esclave.elmt")
|
||||
SHARED_UUID = "{11111111-2222-3333-4444-555555555555}"
|
||||
# The shipped "going/coming arrow" pair -- a folio-jump link, next_report on
|
||||
# one folio linked to previous_report on the next, one terminal each. Same
|
||||
# category of element as issue #974's custom "naechste_folie_rechts.elmt" /
|
||||
@@ -328,6 +329,29 @@ class EditValidation(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "list of strings"):
|
||||
self.build([{"op": "add_autonum", "kind": "conductor", "name": "a", "parts": "string:W"}])
|
||||
|
||||
def test_a_uuid_index_is_resolved_at_run_time(self):
|
||||
u = "{11111111-1111-4111-8111-111111111111}"
|
||||
script = self.build([{"op": "set_shape", "folio": 2, "index": u,
|
||||
"property": "fill", "value": "none"},
|
||||
{"op": "delete_text", "folio": 1, "index": u},
|
||||
{"op": "delete_image", "folio": 0, "index": u}])
|
||||
self.assertIn(f'qet.setShapeProperty(2, qet.shapeIndex(2, "{u}"), ', script)
|
||||
self.assertIn(f'qet.deleteText(1, qet.textIndex(1, "{u}"))', script)
|
||||
self.assertIn(f'qet.deleteImage(0, qet.imageIndex(0, "{u}"))', script)
|
||||
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
|
||||
self.assertTrue({"shapeIndex", "textIndex", "imageIndex"} <= set(need))
|
||||
|
||||
def test_an_index_only_edit_does_not_need_the_uuid_resolvers(self):
|
||||
script = self.build([{"op": "delete_shape", "folio": 0, "index": 3}])
|
||||
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
|
||||
self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need))
|
||||
|
||||
def test_a_string_index_that_is_not_a_uuid_is_refused(self):
|
||||
for bad in ("3", "{nope}", "11111111-1111"):
|
||||
with self.subTest(index=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
self.build([{"op": "delete_shape", "folio": 0, "index": bad}])
|
||||
|
||||
def test_indexed_references_and_element_lists(self):
|
||||
script = self.build([
|
||||
{"op": "add_folio", "id": "f"},
|
||||
@@ -536,6 +560,30 @@ class ElementBuild(unittest.TestCase):
|
||||
{"x": 0, "y": -20, "orientation": "n", "name": "high"}])
|
||||
self.assertEqual(r["terminal_index_order"], ["high", "low"])
|
||||
|
||||
def test_every_part_gets_its_own_uuid(self):
|
||||
parts = [{"type": "line", "x1": 0, "y1": 0, "x2": 10, "y2": 0},
|
||||
{"type": "rect", "x": 0, "y": 0, "width": 5, "height": 5},
|
||||
{"type": "text", "x": 0, "y": 0, "text": "K"}]
|
||||
r = self.build(parts=parts)
|
||||
desc = ET.parse(self.out).getroot().find("description")
|
||||
written = [e.get("uuid") for e in desc if e.tag != "terminal"]
|
||||
self.assertEqual(written, r["part_uuids"])
|
||||
self.assertEqual(len(set(written)), 3)
|
||||
self.assertEqual([p["uuid"] for p in r["verified"]["part_list"]], written)
|
||||
|
||||
def test_a_given_part_uuid_is_kept_and_braced(self):
|
||||
r = self.build(parts=[{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0,
|
||||
"uuid": "11111111-1111-4111-8111-111111111111"}])
|
||||
self.assertEqual(r["part_uuids"], ["{11111111-1111-4111-8111-111111111111}"])
|
||||
|
||||
def test_bad_or_repeated_part_uuids_are_refused(self):
|
||||
line = {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}
|
||||
u = "{11111111-1111-4111-8111-111111111111}"
|
||||
for parts in ([{**line, "uuid": "nope"}], [{**line, "uuid": u}, {**line, "uuid": u}]):
|
||||
with self.subTest(parts=parts):
|
||||
with self.assertRaises(ValueError):
|
||||
self.build(parts=parts)
|
||||
|
||||
def test_output_reads_back(self):
|
||||
r = self.build(names={"en": "Coil", "fr": "Bobine"})
|
||||
self.assertEqual(r["verified"]["names"], {"en": "Coil", "fr": "Bobine"})
|
||||
@@ -632,14 +680,17 @@ class Diff(unittest.TestCase):
|
||||
|
||||
def project(self, name, *, texts=(), shapes=(), images=(), author="", version="1",
|
||||
strips=(), conductors=()):
|
||||
inputs = "".join(f'<input x="{x}" y="{y}" rotation="0" font="f" '
|
||||
# An optional last member of each tuple is the item's uuid.
|
||||
uid = lambda u: f' uuid="{u[0]}"' if u else ""
|
||||
inputs = "".join(f'<input x="{x}" y="{y}" rotation="0" font="f"{uid(u)} '
|
||||
f'text="<html><body><p>{t}</p></body></html>"/>'
|
||||
for x, y, t in texts)
|
||||
shp = "".join(f'<shape type="Rectangle" x1="{a}" y1="{b}" x2="{c}" y2="{d}">'
|
||||
for x, y, t, *u in texts)
|
||||
shp = "".join(f'<shape type="Rectangle" x1="{a}" y1="{b}" x2="{c}" y2="{d}"{uid(u)}>'
|
||||
f'<pen color="{pc}" style="SolidLine" widthF="1"/>'
|
||||
f'<brush color="#fff" style="SolidPattern"/></shape>'
|
||||
for a, b, c, d, pc in shapes)
|
||||
img = "".join(f'<image x="{x}" y="{y}" size="{s}" rotation="0"/>' for x, y, s in images)
|
||||
for a, b, c, d, pc, *u in shapes)
|
||||
img = "".join(f'<image x="{x}" y="{y}" size="{s}" rotation="0"{uid(u)}/>'
|
||||
for x, y, s, *u in images)
|
||||
st = "".join(f'<terminal_strip><terminal_strip_data uuid="{u}"><informations>'
|
||||
f'<information name="name">{n}</information></informations>'
|
||||
f'</terminal_strip_data><layout/></terminal_strip>' for u, n in strips)
|
||||
@@ -669,6 +720,39 @@ class Diff(unittest.TestCase):
|
||||
self.assertEqual([t["text"] for t in d["removed"]], ["note"])
|
||||
self.assertEqual([t["text"] for t in d["added"]], ["note EDITED"])
|
||||
|
||||
U1 = "{11111111-1111-4111-8111-111111111111}"
|
||||
U2 = "{22222222-2222-4222-8222-222222222222}"
|
||||
|
||||
def test_edited_text_with_a_uuid_is_a_change_to_that_text(self):
|
||||
a = self.project("a.qet", texts=[(1, 2, "note", self.U1)])
|
||||
b = self.project("b.qet", texts=[(1, 2, "note EDITED", self.U1)])
|
||||
d = m.tool_diff(a, b)["texts"]
|
||||
self.assertEqual(d["keyed_by"], "uuid")
|
||||
self.assertEqual((d["added"], d["removed"]), ([], []))
|
||||
self.assertEqual(d["changed"][0]["changed"]["text"], ["note", "note EDITED"])
|
||||
|
||||
def test_moved_shape_with_a_uuid_is_a_change_to_that_shape(self):
|
||||
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000", self.U1),
|
||||
(9, 9, 12, 12, "#000000", self.U2)])
|
||||
b = self.project("b.qet", shapes=[(100, 0, 105, 5, "#000000", self.U1),
|
||||
(9, 9, 12, 12, "#000000", self.U2)])
|
||||
d = m.tool_diff(a, b)["shapes"]
|
||||
self.assertEqual((d["added"], d["removed"]), ([], []))
|
||||
self.assertEqual(len(d["changed"]), 1)
|
||||
self.assertEqual(d["changed"][0]["item"]["uuid"], self.U1)
|
||||
self.assertEqual(d["changed"][0]["changed"]["from"], [["0", "0"], ["100", "0"]])
|
||||
|
||||
def test_a_file_without_uuids_against_its_resave_is_keyed_by_position(self):
|
||||
"""The first save by a current QElectroTech adds uuids to a legacy
|
||||
file: that must not read as every item removed and re-added."""
|
||||
a = self.project("a.qet", texts=[(1, 2, "note")], images=[(3, 3, 1)])
|
||||
b = self.project("b.qet", texts=[(1, 2, "note", self.U1)], images=[(3, 3, 1, self.U2)])
|
||||
d = m.tool_diff(a, b)
|
||||
for k in ("texts", "images"):
|
||||
with self.subTest(section=k):
|
||||
self.assertEqual(d[k]["keyed_by"], "position")
|
||||
self.assertFalse(d[k]["added"] or d[k]["removed"] or d[k]["changed"])
|
||||
|
||||
def test_shape_restyle_is_a_change_to_that_item(self):
|
||||
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000")])
|
||||
b = self.project("b.qet", shapes=[(0, 0, 5, 5, "#ff0000")])
|
||||
@@ -1975,6 +2059,85 @@ class Integration(unittest.TestCase):
|
||||
self.assertEqual(len(d["shapes"]["added"]), 1)
|
||||
self.assertEqual(len(d["images"]["added"]), 1)
|
||||
|
||||
def test_drawing_items_are_in_the_database_and_addressable_by_uuid(self):
|
||||
base = self.sb.new()
|
||||
r = self.ok(self.sb.edit(base, [
|
||||
{"op": "add_text", "folio": 0, "text": "note", "x": 50, "y": 50},
|
||||
{"op": "add_shape", "folio": 0, "shape": "rectangle", "x1": 10, "y1": 10, "x2": 200, "y2": 120}]))
|
||||
root = ET.parse(r["output"]).getroot()
|
||||
shape_uuid = next(root.iter("shape")).get("uuid")
|
||||
text_uuid = next(root.iter("input")).get("uuid")
|
||||
rows = m.tool_query(BINARY, r["output"],
|
||||
"SELECT uuid, kind, folio, description FROM drawing_item_view "
|
||||
"ORDER BY kind")["rows"]
|
||||
self.assertEqual([(x["uuid"], x["kind"], x["folio"]) for x in rows],
|
||||
[(shape_uuid, "shape", 1), (text_uuid, "text", 1)])
|
||||
self.assertEqual(rows[1]["description"], "note")
|
||||
|
||||
# A second shape placed above the first shifts its index to 1; the
|
||||
# uuid still names it.
|
||||
r2 = self.ok(self.sb.edit(r["output"], [
|
||||
{"op": "add_shape", "folio": 0, "shape": "line", "x1": 0, "y1": 0, "x2": 5, "y2": 0},
|
||||
{"op": "set_shape", "folio": 0, "index": shape_uuid, "property": "fill", "value": "#00ff00"}],
|
||||
out="out2.qet"))
|
||||
d = r2["diff"]["shapes"]
|
||||
self.assertEqual(d["keyed_by"], "uuid")
|
||||
self.assertEqual([c["item"]["uuid"] for c in d["changed"]], [shape_uuid])
|
||||
self.assertEqual(len(d["added"]), 1)
|
||||
|
||||
def _old_file(self, same_shape_uuid=False):
|
||||
"""A folio with a text and two shapes, as a version without
|
||||
drawing-item uuids wrote it -- or, with same_shape_uuid, hand-edited
|
||||
so that both shapes claim the same one."""
|
||||
r = self.ok(self.sb.edit(self.sb.new(), [
|
||||
{"op": "add_text", "folio": 0, "text": "note", "x": 50, "y": 50},
|
||||
{"op": "add_shape", "folio": 0, "shape": "rectangle", "x1": 10, "y1": 10, "x2": 200, "y2": 120},
|
||||
{"op": "add_shape", "folio": 0, "shape": "line", "x1": 0, "y1": 0, "x2": 5, "y2": 0}]))
|
||||
tree = ET.parse(r["output"])
|
||||
shapes = list(tree.getroot().iter("shape"))
|
||||
for item in [*tree.getroot().iter("input"), *shapes]:
|
||||
del item.attrib["uuid"]
|
||||
if same_shape_uuid:
|
||||
for shape in shapes:
|
||||
shape.set("uuid", SHARED_UUID)
|
||||
tree.write(self.sb.p("old.qet"), encoding="utf-8", xml_declaration=True)
|
||||
return self.sb.p("old.qet")
|
||||
|
||||
def _db_uuids(self, project):
|
||||
rows = m.tool_query(BINARY, project, "SELECT uuid, kind FROM drawing_item_view")["rows"]
|
||||
return sorted((x["uuid"], x["kind"]) for x in rows)
|
||||
|
||||
@staticmethod
|
||||
def _file_uuids(project):
|
||||
root = ET.parse(project).getroot()
|
||||
return sorted((item.get("uuid"), kind)
|
||||
for tag, kind in (("shape", "shape"), ("input", "text"))
|
||||
for item in root.iter(tag))
|
||||
|
||||
def test_a_file_without_drawing_uuids_gets_the_same_ones_on_every_open(self):
|
||||
old = self._old_file()
|
||||
first = self._db_uuids(old)
|
||||
self.assertEqual(len(first), 3)
|
||||
self.assertEqual(len({u for u, _ in first}), 3, "each item gets its own")
|
||||
self.assertEqual(self._db_uuids(old), first)
|
||||
|
||||
def test_the_first_save_writes_those_uuids_and_a_reopen_keeps_them(self):
|
||||
old = self._old_file()
|
||||
derived = self._db_uuids(old)
|
||||
saved = self.ok(self.sb.edit(old, [{"op": "add_folio"}], out="saved.qet"))["output"]
|
||||
self.assertEqual(self._file_uuids(saved), derived)
|
||||
self.assertEqual(self._db_uuids(saved), derived)
|
||||
# Saving the same old file again writes the same uuids (#754).
|
||||
again = self.ok(self.sb.edit(old, [{"op": "add_folio"}], out="again.qet"))["output"]
|
||||
self.assertEqual(self._file_uuids(again), derived)
|
||||
|
||||
def test_a_uuid_repeated_in_a_hand_edited_file_names_one_item_only(self):
|
||||
old = self._old_file(same_shape_uuid=True)
|
||||
shapes = [u for u, kind in self._db_uuids(old) if kind == "shape"]
|
||||
self.assertEqual(len(set(shapes)), 2)
|
||||
self.assertIn(SHARED_UUID, shapes)
|
||||
self.assertEqual([u for u, kind in self._db_uuids(old) if kind == "shape"], shapes)
|
||||
|
||||
# ---- polygon and path shapes ----
|
||||
|
||||
def test_add_polygon_with_more_than_two_points(self):
|
||||
|
||||
+545
-81
@@ -14,22 +14,59 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Record raw USB reports from a 3Dconnexion 3D mouse, for QElectroTech.
|
||||
r"""Record raw USB reports from a 3Dconnexion 3D mouse, for QElectroTech.
|
||||
|
||||
QElectroTech's Windows/macOS 3D mouse support reads the device directly
|
||||
over USB, so it has to decode each device model's raw reports itself.
|
||||
This records what your device sends while you make a few guided
|
||||
movements, and saves it to one file you can attach to the discussion.
|
||||
Nothing is sent anywhere.
|
||||
movements, and saves it to one file you can attach to discussion #599.
|
||||
Nothing is sent anywhere. One recording per device model is enough, from
|
||||
any of the three systems: the device sends the same reports on all of them.
|
||||
|
||||
sudo python3 spacemouse-capture.py # finds the device itself
|
||||
sudo python3 spacemouse-capture.py --list # just show what it finds
|
||||
It takes about two minutes. Each step says what to do: press Enter, make
|
||||
the movement and hold it until the next prompt. Push firmly.
|
||||
|
||||
sudo is needed because /dev/hidraw* is usually readable by root only.
|
||||
spacenavd can keep running. If the recording comes out empty, stop it
|
||||
(`sudo systemctl stop spacenavd`) and try again.
|
||||
Linux
|
||||
Python 3 is already installed.
|
||||
1. Download: curl -LO https://raw.githubusercontent.com/qelectrotech/qelectrotech-source-mirror/master/misc/spacemouse-capture.py
|
||||
2. List: sudo python3 spacemouse-capture.py --list
|
||||
3. Record: sudo python3 spacemouse-capture.py --seconds 3
|
||||
If --list shows several devices (a Logitech receiver shows up as
|
||||
several), add --device /dev/hidrawN with the 3D mouse's line.
|
||||
sudo is needed because /dev/hidraw* is readable by root only.
|
||||
spacenavd can keep running; if the recording comes out empty, stop it
|
||||
(sudo systemctl stop spacenavd) and try again.
|
||||
|
||||
Only the standard library is used, so it runs on any Linux with Python 3.
|
||||
macOS
|
||||
Python 3 comes with the Xcode command line tools; if "python3" asks
|
||||
to install them, accept. Open Terminal, then:
|
||||
1. Download: curl -LO https://raw.githubusercontent.com/qelectrotech/qelectrotech-source-mirror/master/misc/spacemouse-capture.py
|
||||
2. Record: python3 spacemouse-capture.py --seconds 3
|
||||
No sudo. If it says another program holds the device, quit 3DxWare
|
||||
(or uninstall it) and try again. If it says macOS refused access,
|
||||
allow Terminal in System Settings > Privacy & Security > Input
|
||||
Monitoring, and try again. (Rather not use Terminal? The SpaceMouse
|
||||
Check app does the same with a window: see discussion #599.)
|
||||
|
||||
Windows
|
||||
Install Python 3 from https://www.python.org/downloads/ (tick "Add
|
||||
python.exe to PATH") or from the Microsoft Store. Open a Command
|
||||
Prompt (Windows key, type cmd, Enter), then:
|
||||
1. cd %USERPROFILE%\Downloads
|
||||
2. Download: curl -LO https://raw.githubusercontent.com/qelectrotech/qelectrotech-source-mirror/master/misc/spacemouse-capture.py
|
||||
3. Record: python spacemouse-capture.py --seconds 3
|
||||
No administrator rights needed, and 3DxWare can keep running.
|
||||
Windows does not give out the device's report descriptor, so a
|
||||
recording made on Linux or macOS is a little more complete.
|
||||
|
||||
Every system
|
||||
--list only show the 3D mice found
|
||||
--device PATH pick one, if several are found (a path from --list)
|
||||
--seconds N multiply the time per step (3 triples it)
|
||||
-o FILE where to save (default: spacemouse-capture-<id>.json)
|
||||
|
||||
The file is saved in the current folder: attach it to discussion #599.
|
||||
Only Python's standard library is used.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
@@ -38,6 +75,7 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -58,134 +96,560 @@ STEPS = [
|
||||
('buttons', 'Press each button once, slowly, one at a time, in any order.', 15),
|
||||
]
|
||||
|
||||
# Processes that may also be reading the device.
|
||||
OTHER_READERS = ('3dconnexion', '3dx', 'spacenavd') # 3DxWare: 3DconnexionHelper, 3DxNLServer...
|
||||
|
||||
def find_devices():
|
||||
"""Return [{hidraw, name, vendor, product, sysfs}] for 3Dconnexion devices."""
|
||||
found = []
|
||||
for sysdir in sorted(glob.glob('/sys/class/hidraw/hidraw*')):
|
||||
|
||||
def other_readers():
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
with open(os.path.join(sysdir, 'device', 'uevent')) as f:
|
||||
uevent = dict(line.strip().split('=', 1) for line in f if '=' in line)
|
||||
except OSError:
|
||||
continue
|
||||
# HID_ID=0003:0000256F:0000C635 (bus:vendor:product)
|
||||
try:
|
||||
_bus, vendor, product = (int(x, 16) for x in uevent.get('HID_ID', '').split(':'))
|
||||
except ValueError:
|
||||
continue
|
||||
if vendor not in VENDORS:
|
||||
continue
|
||||
found.append({
|
||||
'hidraw': '/dev/' + os.path.basename(sysdir),
|
||||
'name': uevent.get('HID_NAME', '?'),
|
||||
'vendor': '%04x' % vendor,
|
||||
'product': '%04x' % product,
|
||||
'sysfs': sysdir,
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
def read_descriptor(sysdir):
|
||||
out = subprocess.run(['tasklist', '/fo', 'csv', '/nh'], capture_output=True,
|
||||
text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ['unknown']
|
||||
names = {line.split('","')[0].strip('"') for line in out.splitlines() if line}
|
||||
return sorted(n for n in names if any(r in n.lower() for r in OTHER_READERS))
|
||||
try:
|
||||
with open(os.path.join(sysdir, 'device', 'report_descriptor'), 'rb') as f:
|
||||
return f.read().hex()
|
||||
except OSError as e:
|
||||
return 'unreadable: %s' % e
|
||||
out = subprocess.run(['ps', '-A', '-o', 'comm='], capture_output=True,
|
||||
text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ['unknown']
|
||||
names = {os.path.basename(line.strip()) for line in out.splitlines()}
|
||||
return sorted(n for n in names if any(r in n.lower() for r in OTHER_READERS))
|
||||
|
||||
|
||||
def record(fd, seconds):
|
||||
"""Read every report arriving within `seconds`; return [[ms, hex], ...]."""
|
||||
reports = []
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
left = seconds - (time.monotonic() - start)
|
||||
if left <= 0:
|
||||
return reports
|
||||
ready, _, _ = select.select([fd], [], [], left)
|
||||
if not ready:
|
||||
continue
|
||||
# --- Linux: /dev/hidraw ----------------------------------------------------
|
||||
|
||||
class HidrawDevice:
|
||||
backend = 'hidraw'
|
||||
|
||||
def __init__(self, path, name='?', vendor='?', product='?', sysfs=None):
|
||||
self.path, self.name, self.vendor, self.product = path, name, vendor, product
|
||||
self.sysfs = sysfs
|
||||
self.fd = None
|
||||
|
||||
@staticmethod
|
||||
def find():
|
||||
found = []
|
||||
for sysdir in sorted(glob.glob('/sys/class/hidraw/hidraw*')):
|
||||
try:
|
||||
with open(os.path.join(sysdir, 'device', 'uevent')) as f:
|
||||
uevent = dict(line.strip().split('=', 1) for line in f if '=' in line)
|
||||
except OSError:
|
||||
continue
|
||||
# HID_ID=0003:0000256F:0000C635 (bus:vendor:product)
|
||||
try:
|
||||
_bus, vendor, product = (int(x, 16) for x in uevent.get('HID_ID', '').split(':'))
|
||||
except ValueError:
|
||||
continue
|
||||
if vendor not in VENDORS:
|
||||
continue
|
||||
found.append(HidrawDevice('/dev/' + os.path.basename(sysdir),
|
||||
uevent.get('HID_NAME', '?'),
|
||||
'%04x' % vendor, '%04x' % product, sysdir))
|
||||
return found
|
||||
|
||||
def descriptor(self):
|
||||
if not self.sysfs:
|
||||
return 'unknown'
|
||||
try:
|
||||
data = os.read(fd, 64)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not data: # only a test FIFO with no writer does this
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
reports.append([round((time.monotonic() - start) * 1000, 1), data.hex()])
|
||||
with open(os.path.join(self.sysfs, 'device', 'report_descriptor'), 'rb') as f:
|
||||
return f.read().hex()
|
||||
except OSError as e:
|
||||
return 'unreadable: %s' % e
|
||||
|
||||
def open(self):
|
||||
try:
|
||||
self.fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
except PermissionError:
|
||||
sys.exit('Permission denied on %s -- run with sudo.' % self.path)
|
||||
|
||||
def record(self, seconds):
|
||||
"""Read every report arriving within `seconds`; return [[ms, hex], ...]."""
|
||||
reports = []
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
left = seconds - (time.monotonic() - start)
|
||||
if left <= 0:
|
||||
return reports
|
||||
ready, _, _ = select.select([self.fd], [], [], left)
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
data = os.read(self.fd, 64)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not data: # only a test FIFO with no writer does this
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
reports.append([round((time.monotonic() - start) * 1000, 1), data.hex()])
|
||||
|
||||
def drain(self):
|
||||
"""Drop reports queued while waiting for Enter."""
|
||||
while True:
|
||||
try:
|
||||
if not os.read(self.fd, 64):
|
||||
return
|
||||
except BlockingIOError:
|
||||
return
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
def empty_hint(self):
|
||||
return 'Try stopping spacenavd first: sudo systemctl stop spacenavd'
|
||||
|
||||
|
||||
# --- macOS: IOKit, the same calls hidapi's mac backend makes ----------------
|
||||
|
||||
class MacHid:
|
||||
"""ctypes bindings to the few CoreFoundation/IOKit calls needed."""
|
||||
|
||||
def __init__(self):
|
||||
import ctypes as c
|
||||
self.c = c
|
||||
cf = c.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation')
|
||||
io = c.CDLL('/System/Library/Frameworks/IOKit.framework/IOKit')
|
||||
vp, i32, u32, idx = c.c_void_p, c.c_int32, c.c_uint32, c.c_long
|
||||
|
||||
def fn(lib, name, res, *args):
|
||||
f = getattr(lib, name)
|
||||
f.restype, f.argtypes = res, list(args)
|
||||
return f
|
||||
|
||||
self.CFStringCreateWithCString = fn(cf, 'CFStringCreateWithCString', vp, vp, c.c_char_p, u32)
|
||||
self.CFStringGetCString = fn(cf, 'CFStringGetCString', c.c_bool, vp, c.c_char_p, idx, u32)
|
||||
self.CFGetTypeID = fn(cf, 'CFGetTypeID', c.c_ulong, vp)
|
||||
self.CFNumberGetTypeID = fn(cf, 'CFNumberGetTypeID', c.c_ulong)
|
||||
self.CFStringGetTypeID = fn(cf, 'CFStringGetTypeID', c.c_ulong)
|
||||
self.CFDataGetTypeID = fn(cf, 'CFDataGetTypeID', c.c_ulong)
|
||||
self.CFNumberGetValue = fn(cf, 'CFNumberGetValue', c.c_bool, vp, idx, vp)
|
||||
self.CFDataGetLength = fn(cf, 'CFDataGetLength', idx, vp)
|
||||
self.CFDataGetBytePtr = fn(cf, 'CFDataGetBytePtr', vp, vp)
|
||||
self.CFSetGetCount = fn(cf, 'CFSetGetCount', idx, vp)
|
||||
self.CFSetGetValues = fn(cf, 'CFSetGetValues', None, vp, c.POINTER(vp))
|
||||
self.CFRunLoopGetCurrent = fn(cf, 'CFRunLoopGetCurrent', vp)
|
||||
self.CFRunLoopRunInMode = fn(cf, 'CFRunLoopRunInMode', i32, vp, c.c_double, c.c_bool)
|
||||
self.kCFRunLoopDefaultMode = vp.in_dll(cf, 'kCFRunLoopDefaultMode').value
|
||||
|
||||
self.IOHIDManagerCreate = fn(io, 'IOHIDManagerCreate', vp, vp, u32)
|
||||
self.IOHIDManagerSetDeviceMatching = fn(io, 'IOHIDManagerSetDeviceMatching', None, vp, vp)
|
||||
self.IOHIDManagerScheduleWithRunLoop = fn(io, 'IOHIDManagerScheduleWithRunLoop', None, vp, vp, vp)
|
||||
self.IOHIDManagerCopyDevices = fn(io, 'IOHIDManagerCopyDevices', vp, vp)
|
||||
self.IOHIDDeviceGetProperty = fn(io, 'IOHIDDeviceGetProperty', vp, vp, vp)
|
||||
self.IOHIDDeviceOpen = fn(io, 'IOHIDDeviceOpen', i32, vp, u32)
|
||||
self.IOHIDDeviceClose = fn(io, 'IOHIDDeviceClose', i32, vp, u32)
|
||||
self.IOHIDDeviceScheduleWithRunLoop = fn(io, 'IOHIDDeviceScheduleWithRunLoop', None, vp, vp, vp)
|
||||
self.IOHIDDeviceUnscheduleFromRunLoop = fn(io, 'IOHIDDeviceUnscheduleFromRunLoop', None, vp, vp, vp)
|
||||
# void (*)(void *ctx, IOReturn, void *sender, IOHIDReportType, uint32_t id, uint8_t *, CFIndex)
|
||||
self.ReportCallback = c.CFUNCTYPE(None, vp, i32, vp, c.c_int, u32, c.POINTER(c.c_uint8), idx)
|
||||
self.IOHIDDeviceRegisterInputReportCallback = fn(
|
||||
io, 'IOHIDDeviceRegisterInputReportCallback', None, vp, vp, idx, self.ReportCallback, vp)
|
||||
|
||||
def cfstr(self, s):
|
||||
return self.CFStringCreateWithCString(None, s.encode(), 0x08000100) # UTF-8
|
||||
|
||||
def prop(self, dev, key):
|
||||
"""A device property as int, str or bytes, or None."""
|
||||
c = self.c
|
||||
ref = self.IOHIDDeviceGetProperty(dev, self.cfstr(key))
|
||||
if not ref:
|
||||
return None
|
||||
t = self.CFGetTypeID(ref)
|
||||
if t == self.CFNumberGetTypeID():
|
||||
v = c.c_int64()
|
||||
self.CFNumberGetValue(ref, 4, c.byref(v)) # kCFNumberSInt64Type
|
||||
return v.value
|
||||
if t == self.CFStringGetTypeID():
|
||||
buf = c.create_string_buffer(256)
|
||||
return buf.value.decode('utf-8', 'replace') if self.CFStringGetCString(
|
||||
ref, buf, len(buf), 0x08000100) else None
|
||||
if t == self.CFDataGetTypeID():
|
||||
return c.string_at(self.CFDataGetBytePtr(ref), self.CFDataGetLength(ref))
|
||||
return None
|
||||
|
||||
|
||||
class MacDevice:
|
||||
backend = 'iokit'
|
||||
_hid = None
|
||||
|
||||
def __init__(self, ref, name, vendor, product, location):
|
||||
self.ref, self.name, self.vendor, self.product = ref, name, vendor, product
|
||||
self.path = 'iokit:%08x' % location
|
||||
self.reports = []
|
||||
self.start = time.monotonic()
|
||||
|
||||
@classmethod
|
||||
def hid(cls):
|
||||
if cls._hid is None:
|
||||
cls._hid = MacHid()
|
||||
return cls._hid
|
||||
|
||||
@classmethod
|
||||
def find(cls):
|
||||
h = cls.hid()
|
||||
mgr = h.IOHIDManagerCreate(None, 0)
|
||||
h.IOHIDManagerSetDeviceMatching(mgr, None)
|
||||
h.IOHIDManagerScheduleWithRunLoop(mgr, h.CFRunLoopGetCurrent(), h.kCFRunLoopDefaultMode)
|
||||
devset = h.IOHIDManagerCopyDevices(mgr)
|
||||
if not devset:
|
||||
return []
|
||||
n = h.CFSetGetCount(devset)
|
||||
refs = (h.c.c_void_p * n)()
|
||||
h.CFSetGetValues(devset, refs)
|
||||
found = []
|
||||
for ref in refs:
|
||||
vendor = h.prop(ref, 'VendorID')
|
||||
if vendor not in VENDORS:
|
||||
continue
|
||||
# The same test as QET's SpaceMouseHid::isSpaceMouse(): Generic
|
||||
# Desktop / Multi-axis Controller, or no usage at all. This also
|
||||
# skips Logitech's ordinary mice and keyboards.
|
||||
usage = (h.prop(ref, 'PrimaryUsagePage') or 0, h.prop(ref, 'PrimaryUsage') or 0)
|
||||
if usage not in ((1, 8), (0, 0)):
|
||||
continue
|
||||
found.append(cls(ref, h.prop(ref, 'Product') or '?', '%04x' % vendor,
|
||||
'%04x' % (h.prop(ref, 'ProductID') or 0),
|
||||
h.prop(ref, 'LocationID') or 0))
|
||||
return sorted(found, key=lambda d: d.path)
|
||||
|
||||
def descriptor(self):
|
||||
d = self.hid().prop(self.ref, 'ReportDescriptor')
|
||||
return d.hex() if d else 'unreadable'
|
||||
|
||||
def open(self):
|
||||
h = self.hid()
|
||||
ret = h.IOHIDDeviceOpen(self.ref, 0) & 0xFFFFFFFF # kIOHIDOptionsTypeNone: shared
|
||||
if ret == 0xE00002C5:
|
||||
sys.exit('The device is held exclusively by another program '
|
||||
'(kIOReturnExclusiveAccess). Quit 3DxWare and try again.')
|
||||
if ret in (0xE00002E2, 0xE00002C1):
|
||||
sys.exit('macOS refused access (0x%08X). Allow Terminal under System Settings > '
|
||||
'Privacy & Security > Input Monitoring, then try again.' % ret)
|
||||
if ret:
|
||||
sys.exit('Could not open the device (IOReturn 0x%08X).' % ret)
|
||||
size = h.prop(self.ref, 'MaxInputReportSize') or 64
|
||||
self.buf = h.c.create_string_buffer(size)
|
||||
|
||||
def on_report(_ctx, _result, _sender, _type, _id, data, length):
|
||||
self.reports.append([round((time.monotonic() - self.start) * 1000, 1),
|
||||
h.c.string_at(data, length).hex()])
|
||||
self.callback = h.ReportCallback(on_report) # must outlive the device
|
||||
h.IOHIDDeviceRegisterInputReportCallback(self.ref, self.buf, size, self.callback, None)
|
||||
h.IOHIDDeviceScheduleWithRunLoop(self.ref, h.CFRunLoopGetCurrent(), h.kCFRunLoopDefaultMode)
|
||||
|
||||
def record(self, seconds):
|
||||
h = self.hid()
|
||||
self.reports, self.start = [], time.monotonic()
|
||||
while True:
|
||||
left = seconds - (time.monotonic() - self.start)
|
||||
if left <= 0:
|
||||
return self.reports
|
||||
h.CFRunLoopRunInMode(h.kCFRunLoopDefaultMode, left, False)
|
||||
|
||||
def drain(self):
|
||||
self.hid().CFRunLoopRunInMode(self.hid().kCFRunLoopDefaultMode, 0.05, False)
|
||||
self.reports = []
|
||||
|
||||
def close(self):
|
||||
h = self.hid()
|
||||
h.IOHIDDeviceUnscheduleFromRunLoop(self.ref, h.CFRunLoopGetCurrent(), h.kCFRunLoopDefaultMode)
|
||||
h.IOHIDDeviceClose(self.ref, 0)
|
||||
|
||||
def empty_hint(self):
|
||||
return ('Quit 3DxWare if it is running, or allow Terminal under System Settings > '
|
||||
'Privacy & Security > Input Monitoring, and try again.')
|
||||
|
||||
|
||||
# --- Windows: hid.dll, the same calls hidapi's Windows backend makes ---------
|
||||
|
||||
class WinHid:
|
||||
"""ctypes bindings to the SetupAPI/HID/kernel32 calls needed."""
|
||||
|
||||
def __init__(self):
|
||||
import ctypes as c
|
||||
from ctypes import wintypes as w
|
||||
self.c, self.w = c, w
|
||||
self.hid = c.WinDLL('hid')
|
||||
self.setupapi = c.WinDLL('setupapi')
|
||||
self.k32 = c.WinDLL('kernel32', use_last_error=True)
|
||||
k, sa = self.k32, self.setupapi
|
||||
|
||||
class GUID(c.Structure):
|
||||
_fields_ = [('Data1', w.DWORD), ('Data2', w.WORD), ('Data3', w.WORD),
|
||||
('Data4', c.c_ubyte * 8)]
|
||||
|
||||
class InterfaceData(c.Structure):
|
||||
_fields_ = [('cbSize', w.DWORD), ('InterfaceClassGuid', GUID),
|
||||
('Flags', w.DWORD), ('Reserved', c.c_void_p)]
|
||||
|
||||
class Attributes(c.Structure):
|
||||
_fields_ = [('Size', w.ULONG), ('VendorID', w.USHORT), ('ProductID', w.USHORT),
|
||||
('VersionNumber', w.USHORT)]
|
||||
|
||||
class Caps(c.Structure):
|
||||
_fields_ = [('Usage', w.USHORT), ('UsagePage', w.USHORT),
|
||||
('InputReportByteLength', w.USHORT), ('OutputReportByteLength', w.USHORT),
|
||||
('FeatureReportByteLength', w.USHORT), ('Reserved', w.USHORT * 17),
|
||||
('rest', w.USHORT * 10)]
|
||||
|
||||
class Overlapped(c.Structure):
|
||||
_fields_ = [('Internal', c.c_void_p), ('InternalHigh', c.c_void_p),
|
||||
('Offset', w.DWORD), ('OffsetHigh', w.DWORD), ('hEvent', w.HANDLE)]
|
||||
|
||||
self.GUID, self.InterfaceData, self.Attributes = GUID, InterfaceData, Attributes
|
||||
self.Caps, self.Overlapped = Caps, Overlapped
|
||||
|
||||
k.CreateFileW.restype = w.HANDLE
|
||||
k.CreateFileW.argtypes = [w.LPCWSTR, w.DWORD, w.DWORD, c.c_void_p, w.DWORD, w.DWORD, w.HANDLE]
|
||||
k.CreateEventW.restype = w.HANDLE
|
||||
k.CreateEventW.argtypes = [c.c_void_p, w.BOOL, w.BOOL, w.LPCWSTR]
|
||||
k.ReadFile.argtypes = [w.HANDLE, c.c_void_p, w.DWORD, c.c_void_p, c.c_void_p]
|
||||
k.GetOverlappedResult.argtypes = [w.HANDLE, c.c_void_p, c.POINTER(w.DWORD), w.BOOL]
|
||||
k.WaitForSingleObject.argtypes = [w.HANDLE, w.DWORD]
|
||||
k.WaitForSingleObject.restype = w.DWORD
|
||||
k.CancelIo.argtypes = [w.HANDLE]
|
||||
k.CloseHandle.argtypes = [w.HANDLE]
|
||||
sa.SetupDiGetClassDevsW.restype = w.HANDLE
|
||||
sa.SetupDiGetClassDevsW.argtypes = [c.c_void_p, w.LPCWSTR, w.HWND, w.DWORD]
|
||||
sa.SetupDiEnumDeviceInterfaces.argtypes = [w.HANDLE, c.c_void_p, c.c_void_p, w.DWORD, c.c_void_p]
|
||||
sa.SetupDiGetDeviceInterfaceDetailW.argtypes = [w.HANDLE, c.c_void_p, c.c_void_p, w.DWORD,
|
||||
c.POINTER(w.DWORD), c.c_void_p]
|
||||
sa.SetupDiDestroyDeviceInfoList.argtypes = [w.HANDLE]
|
||||
self.hid.HidD_GetHidGuid.argtypes = [c.c_void_p]
|
||||
self.hid.HidD_GetAttributes.argtypes = [w.HANDLE, c.c_void_p]
|
||||
self.hid.HidD_GetPreparsedData.argtypes = [w.HANDLE, c.POINTER(c.c_void_p)]
|
||||
self.hid.HidD_FreePreparsedData.argtypes = [c.c_void_p]
|
||||
self.hid.HidP_GetCaps.argtypes = [c.c_void_p, c.c_void_p]
|
||||
self.hid.HidD_GetProductString.argtypes = [w.HANDLE, c.c_void_p, w.ULONG]
|
||||
|
||||
INVALID = (2 ** 64 - 1, 2 ** 32 - 1, -1) # INVALID_HANDLE_VALUE, 64/32-bit
|
||||
|
||||
def paths(self):
|
||||
"""Every HID interface path on the system."""
|
||||
c, w = self.c, self.w
|
||||
guid = self.GUID()
|
||||
self.hid.HidD_GetHidGuid(c.byref(guid))
|
||||
info = self.setupapi.SetupDiGetClassDevsW(c.byref(guid), None, None, 0x12) # PRESENT|INTERFACE
|
||||
paths = []
|
||||
i = 0
|
||||
while True:
|
||||
data = self.InterfaceData()
|
||||
data.cbSize = c.sizeof(data)
|
||||
if not self.setupapi.SetupDiEnumDeviceInterfaces(info, None, c.byref(guid), i, c.byref(data)):
|
||||
break
|
||||
i += 1
|
||||
needed = w.DWORD()
|
||||
self.setupapi.SetupDiGetDeviceInterfaceDetailW(info, c.byref(data), None, 0, c.byref(needed), None)
|
||||
buf = c.create_string_buffer(needed.value)
|
||||
# SP_DEVICE_INTERFACE_DETAIL_DATA_W: DWORD cbSize, then the path.
|
||||
c.cast(buf, c.POINTER(w.DWORD))[0] = 8 if c.sizeof(c.c_void_p) == 8 else 6
|
||||
if self.setupapi.SetupDiGetDeviceInterfaceDetailW(info, c.byref(data), buf, needed, None, None):
|
||||
paths.append(c.wstring_at(c.addressof(buf) + 4))
|
||||
self.setupapi.SetupDiDestroyDeviceInfoList(info)
|
||||
return paths
|
||||
|
||||
def open(self, path, access):
|
||||
# FILE_SHARE_READ|WRITE, OPEN_EXISTING, FILE_FLAG_OVERLAPPED
|
||||
h = self.k32.CreateFileW(path, access, 3, None, 3, 0x40000000, None)
|
||||
return None if h is None or h in self.INVALID else h
|
||||
|
||||
def describe(self, h):
|
||||
"""(vendor, product, usage_page, usage, input_length, name) of an open handle."""
|
||||
c = self.c
|
||||
attrs = self.Attributes()
|
||||
attrs.Size = c.sizeof(attrs)
|
||||
if not self.hid.HidD_GetAttributes(h, c.byref(attrs)):
|
||||
return None
|
||||
page = usage = length = 0
|
||||
pre = c.c_void_p()
|
||||
if self.hid.HidD_GetPreparsedData(h, c.byref(pre)):
|
||||
caps = self.Caps()
|
||||
self.hid.HidP_GetCaps(pre, c.byref(caps))
|
||||
page, usage, length = caps.UsagePage, caps.Usage, caps.InputReportByteLength
|
||||
self.hid.HidD_FreePreparsedData(pre)
|
||||
name = c.create_unicode_buffer(128)
|
||||
if not self.hid.HidD_GetProductString(h, name, c.sizeof(name)):
|
||||
name.value = '?'
|
||||
return attrs.VendorID, attrs.ProductID, page, usage, length, name.value
|
||||
|
||||
|
||||
class WinDevice:
|
||||
backend = 'windows-hid'
|
||||
_hid = None
|
||||
|
||||
def __init__(self, path, name, vendor, product, length):
|
||||
self.path, self.name, self.vendor, self.product = path, name, vendor, product
|
||||
self.length = length or 64
|
||||
self.handle = None
|
||||
|
||||
@classmethod
|
||||
def hid(cls):
|
||||
if cls._hid is None:
|
||||
cls._hid = WinHid()
|
||||
return cls._hid
|
||||
|
||||
@classmethod
|
||||
def find(cls):
|
||||
h = cls.hid()
|
||||
found = []
|
||||
for path in h.paths():
|
||||
handle = h.open(path, 0) # no access: enough to read attributes
|
||||
if handle is None:
|
||||
continue
|
||||
try:
|
||||
d = h.describe(handle)
|
||||
finally:
|
||||
h.k32.CloseHandle(handle)
|
||||
if not d:
|
||||
continue
|
||||
vendor, product, page, usage, length, name = d
|
||||
# The same test as QET's SpaceMouseHid::isSpaceMouse().
|
||||
if vendor not in VENDORS or (page, usage) not in ((1, 8), (0, 0)):
|
||||
continue
|
||||
found.append(cls(path, name, '%04x' % vendor, '%04x' % product, length))
|
||||
return found
|
||||
|
||||
def descriptor(self):
|
||||
# Windows only gives out a parsed form of it.
|
||||
return ''
|
||||
|
||||
def open(self):
|
||||
h = self.hid()
|
||||
self.handle = h.open(self.path, 0x80000000) # GENERIC_READ
|
||||
if self.handle is None:
|
||||
sys.exit('Could not open the device (error %d).' % h.c.get_last_error())
|
||||
self.event = h.k32.CreateEventW(None, True, False, None)
|
||||
self.buf = h.c.create_string_buffer(self.length)
|
||||
self.pending = False
|
||||
self.start = time.monotonic()
|
||||
|
||||
def _read(self, wait_ms):
|
||||
"""One report if it arrives within wait_ms, else None."""
|
||||
h = self.hid()
|
||||
c, w = h.c, h.w
|
||||
if not self.pending:
|
||||
self.ov = h.Overlapped()
|
||||
self.ov.hEvent = self.event
|
||||
h.k32.ReadFile(self.handle, self.buf, self.length, None, c.byref(self.ov))
|
||||
self.pending = True
|
||||
if h.k32.WaitForSingleObject(self.event, max(0, int(wait_ms))) != 0:
|
||||
return None
|
||||
self.pending = False
|
||||
n = w.DWORD()
|
||||
if not h.k32.GetOverlappedResult(self.handle, c.byref(self.ov), c.byref(n), False):
|
||||
return None
|
||||
data = self.buf.raw[:n.value]
|
||||
# Windows puts a report ID in front even when the device has none;
|
||||
# hidapi drops that 0, so QET never sees it.
|
||||
return data[1:] if data[:1] == b'\0' else data
|
||||
|
||||
def record(self, seconds):
|
||||
reports = []
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
left = seconds - (time.monotonic() - start)
|
||||
if left <= 0:
|
||||
return reports
|
||||
data = self._read(left * 1000)
|
||||
if data:
|
||||
reports.append([round((time.monotonic() - start) * 1000, 1), data.hex()])
|
||||
|
||||
def drain(self):
|
||||
while self._read(0):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
h = self.hid()
|
||||
if self.pending:
|
||||
h.k32.CancelIo(self.handle)
|
||||
h.k32.CloseHandle(self.event)
|
||||
h.k32.CloseHandle(self.handle)
|
||||
|
||||
def empty_hint(self):
|
||||
return 'Check the cable, push the cap firmly, and try again.'
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--list', action='store_true', help='only list matching devices')
|
||||
ap.add_argument('--device', help='hidraw path, if more than one device is found')
|
||||
ap.add_argument('--device', help='device path from --list, if more than one is found')
|
||||
ap.add_argument('--descriptor', help=argparse.SUPPRESS) # testing without a device
|
||||
ap.add_argument('--yes', action='store_true', help=argparse.SUPPRESS) # no Enter prompts
|
||||
ap.add_argument('-o', '--output', help='output file (default: spacemouse-capture-<product>.json)')
|
||||
ap.add_argument('--seconds', type=float, default=1.0,
|
||||
help='multiply each step\'s recording time (default: 1.0, e.g. 3 triples it)')
|
||||
args = ap.parse_args()
|
||||
|
||||
devices = find_devices()
|
||||
Device = {'darwin': MacDevice, 'win32': WinDevice}.get(sys.platform, HidrawDevice)
|
||||
devices = Device.find()
|
||||
if args.list:
|
||||
for d in devices:
|
||||
print('%(hidraw)s %(vendor)s:%(product)s %(name)s' % d)
|
||||
print('%s %s:%s %s' % (d.path, d.vendor, d.product, d.name))
|
||||
if not devices:
|
||||
print('No 3Dconnexion device found under /sys/class/hidraw.')
|
||||
print('No 3Dconnexion device found.')
|
||||
return 0 if devices else 1
|
||||
|
||||
if args.device:
|
||||
dev = next((d for d in devices if d['hidraw'] == args.device),
|
||||
{'hidraw': args.device, 'name': '?', 'vendor': '?', 'product': '?', 'sysfs': None})
|
||||
dev = next((d for d in devices if d.path == args.device), None)
|
||||
if dev is None:
|
||||
if Device is not HidrawDevice:
|
||||
sys.exit('No device %s (try --list)' % args.device)
|
||||
dev = HidrawDevice(args.device)
|
||||
elif len(devices) == 1:
|
||||
dev = devices[0]
|
||||
elif not devices:
|
||||
sys.exit('No 3Dconnexion device found. Is it plugged in? (try --list)')
|
||||
else:
|
||||
sys.exit('Several devices found, pick one with --device:\n' +
|
||||
'\n'.join(' %(hidraw)s %(name)s' % d for d in devices))
|
||||
'\n'.join(' %s %s' % (d.path, d.name) for d in devices))
|
||||
|
||||
if args.descriptor:
|
||||
with open(args.descriptor, 'rb') as f:
|
||||
descriptor = f.read().hex()
|
||||
elif dev['sysfs']:
|
||||
descriptor = read_descriptor(dev['sysfs'])
|
||||
else:
|
||||
descriptor = 'unknown'
|
||||
descriptor = dev.descriptor()
|
||||
|
||||
try:
|
||||
fd = os.open(dev['hidraw'], os.O_RDONLY | os.O_NONBLOCK)
|
||||
except PermissionError:
|
||||
sys.exit('Permission denied on %s -- run with sudo.' % dev['hidraw'])
|
||||
|
||||
print('Recording from %s (%s, %s:%s).' % (dev['hidraw'], dev['name'], dev['vendor'], dev['product']))
|
||||
dev.open()
|
||||
print('Recording from %s (%s, %s:%s).' % (dev.path, dev.name, dev.vendor, dev.product))
|
||||
readers = other_readers()
|
||||
if readers:
|
||||
print('Also running: %s. If nothing gets recorded, quit it and try again.' % ', '.join(readers))
|
||||
print('For each step, press Enter, do the movement, and wait for the next prompt.\n')
|
||||
|
||||
result = {
|
||||
'tool': 'spacemouse-capture.py 1',
|
||||
'tool': 'spacemouse-capture.py 2',
|
||||
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||
'system': platform.platform(),
|
||||
'device': {k: dev[k] for k in ('name', 'vendor', 'product')},
|
||||
'backend': dev.backend,
|
||||
'other_readers': readers,
|
||||
'device': {'name': dev.name, 'vendor': dev.vendor, 'product': dev.product},
|
||||
'report_descriptor': descriptor,
|
||||
'steps': [],
|
||||
}
|
||||
try:
|
||||
for i, (key, text, seconds) in enumerate(STEPS, 1):
|
||||
seconds = seconds * args.seconds
|
||||
print('[%d/%d] %s' % (i, len(STEPS), text))
|
||||
if not args.yes:
|
||||
input(' Press Enter to start (%d s)... ' % seconds)
|
||||
reports = record(fd, seconds)
|
||||
input(' Press Enter to start (%.0f s)... ' % seconds)
|
||||
dev.drain()
|
||||
reports = dev.record(seconds)
|
||||
print(' %d reports recorded.\n' % len(reports))
|
||||
result['steps'].append({'step': key, 'instruction': text, 'reports': reports})
|
||||
except KeyboardInterrupt:
|
||||
print('\nStopped early -- saving what was recorded so far.')
|
||||
finally:
|
||||
os.close(fd)
|
||||
dev.close()
|
||||
|
||||
out = args.output or 'spacemouse-capture-%s.json' % dev['product']
|
||||
out = args.output or 'spacemouse-capture-%s.json' % dev.product
|
||||
with open(out, 'w') as f:
|
||||
json.dump(result, f, indent=1)
|
||||
total = sum(len(s['reports']) for s in result['steps'])
|
||||
print('Saved %s (%d reports in total).' % (out, total))
|
||||
if total == 0:
|
||||
print('Nothing was recorded. Try stopping spacenavd first: sudo systemctl stop spacenavd')
|
||||
print('Nothing was recorded. ' + dev.empty_hint())
|
||||
else:
|
||||
print('Please attach this file to discussion #599. Thank you!')
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,934 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "elementpickerpopup.h"
|
||||
|
||||
#include "elementscollectionwidget.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QListWidget>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QToolButton>
|
||||
#include <QCloseEvent>
|
||||
#include <QCursor>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QScreen>
|
||||
#include <QSizeGrip>
|
||||
#include <QStandardItemModel>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QSettings>
|
||||
|
||||
#include <limits>
|
||||
|
||||
#include "../qetapp.h"
|
||||
#include "../shortcutmanager.h"
|
||||
#include "elementslocation.h"
|
||||
|
||||
//The palette is read from disk each time the picker opens. It is meant as
|
||||
//a shortlist, and a large custom collection would otherwise make opening
|
||||
//slow and the grid unusable.
|
||||
static const int max_palette_entries = 60;
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::ElementPickerPopup
|
||||
@param source : the collection widget whose model the search runs against
|
||||
@param parent
|
||||
*/
|
||||
ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source,
|
||||
QWidget *parent) :
|
||||
QFrame(parent, Qt::Popup),
|
||||
m_source(source)
|
||||
{
|
||||
setFrameShape(QFrame::StyledPanel);
|
||||
setFrameShadow(QFrame::Raised);
|
||||
setMinimumWidth(340);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(6, 6, 6, 6);
|
||||
layout->setSpacing(4);
|
||||
|
||||
//Command row, shown when the picker is opened as the shortcut bar
|
||||
m_commands = new QWidget(this);
|
||||
m_commands_layout = new QHBoxLayout(m_commands);
|
||||
m_commands_layout->setContentsMargins(0, 0, 0, 0);
|
||||
m_commands_layout->setSpacing(2);
|
||||
m_commands->hide();
|
||||
//SolidWorks: right-click the bar to customise it
|
||||
m_commands->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(m_commands, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) {
|
||||
QMenu menu;
|
||||
menu.addAction(tr("Personnaliser la barre…"), this, &ElementPickerPopup::startCustomising);
|
||||
menu.exec(m_commands->mapToGlobal(pos));
|
||||
});
|
||||
|
||||
//Customising, shown instead of everything else. Two lists that
|
||||
//commands are dragged between: the bar, left to right, and the
|
||||
//commands that are not on it.
|
||||
m_editor = new QWidget(this);
|
||||
auto *editor_layout = new QVBoxLayout(m_editor);
|
||||
editor_layout->setContentsMargins(0, 0, 0, 0);
|
||||
auto *help = new QLabel(
|
||||
tr("Glissez les commandes et les éléments dans la barre, hors de la "
|
||||
"barre, ou d'une place à l'autre. Un double-clic fait passer une "
|
||||
"commande ou un élément d'une liste à l'autre."), m_editor);
|
||||
help->setWordWrap(true);
|
||||
m_edit_row = new QListWidget(m_editor);
|
||||
m_edit_row->setFlow(QListView::LeftToRight);
|
||||
m_edit_row->setWrapping(false);
|
||||
m_edit_row->setIconSize(QSize(24, 24));
|
||||
m_edit_row->setFixedHeight(44);
|
||||
m_edit_available = new QListWidget(m_editor);
|
||||
m_edit_available->setIconSize(QSize(20, 20));
|
||||
m_edit_available->setMinimumHeight(220);
|
||||
for (QListWidget *list : {m_edit_row, m_edit_available}) {
|
||||
list->setDragDropMode(QAbstractItemView::DragDrop);
|
||||
list->setDefaultDropAction(Qt::MoveAction);
|
||||
list->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
}
|
||||
//A drop copies the item as it was in the other list, so a command
|
||||
//dragged onto the bar would keep its text and one dragged off would
|
||||
//have none. Set the text for the list it has landed in.
|
||||
//Deferred: a drop inserts the row first and copies the item's data
|
||||
//into it afterwards.
|
||||
auto relabel = [this](QListWidget *list, bool icon_only) {
|
||||
return [this, list, icon_only]() {
|
||||
QTimer::singleShot(0, this, [list, icon_only]() {
|
||||
for (int i = 0 ; i < list->count() ; ++i) {
|
||||
QListWidgetItem *item = list->item(i);
|
||||
item->setText(icon_only && !item->icon().isNull()
|
||||
? QString() : item->toolTip());
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
connect(m_edit_row->model(), &QAbstractItemModel::rowsInserted,
|
||||
this, relabel(m_edit_row, true));
|
||||
connect(m_edit_available->model(), &QAbstractItemModel::rowsInserted,
|
||||
this, relabel(m_edit_available, false));
|
||||
//An element dragged off the bar onto the commands is removed from
|
||||
//the bar, not listed as a command
|
||||
connect(m_edit_available->model(), &QAbstractItemModel::rowsInserted, this, [this]() {
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
for (int i = m_edit_available->count() - 1 ; i >= 0 ; --i) {
|
||||
if (ShortcutBarSettings::isElement(
|
||||
m_edit_available->item(i)->data(Qt::UserRole).toString())) {
|
||||
delete m_edit_available->takeItem(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
connect(m_edit_row, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
|
||||
const QString id = item->data(Qt::UserRole).toString();
|
||||
delete item;
|
||||
if (!ShortcutBarSettings::isElement(id)) {
|
||||
m_edit_available->addItem(barItem(id, false));
|
||||
}
|
||||
});
|
||||
connect(m_edit_available, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
|
||||
const QString id = item->data(Qt::UserRole).toString();
|
||||
delete item;
|
||||
m_edit_row->addItem(barItem(id, true));
|
||||
});
|
||||
|
||||
//Elements to pin: the same ranked search as the picker, dragged
|
||||
//onto the bar. Copied, not moved, so a result can be pinned and
|
||||
//still be seen in the list.
|
||||
m_edit_symbols_box = new QWidget(m_editor);
|
||||
auto *symbols_layout = new QVBoxLayout(m_edit_symbols_box);
|
||||
symbols_layout->setContentsMargins(0, 0, 0, 0);
|
||||
m_edit_symbols_search = new QLineEdit(m_edit_symbols_box);
|
||||
m_edit_symbols_search->setPlaceholderText(tr("Rechercher un élément…"));
|
||||
m_edit_symbols_search->setClearButtonEnabled(true);
|
||||
m_edit_symbols = new QListWidget(m_edit_symbols_box);
|
||||
m_edit_symbols->setIconSize(QSize(32, 32));
|
||||
m_edit_symbols->setMinimumHeight(220);
|
||||
m_edit_symbols->setDragDropMode(QAbstractItemView::DragOnly);
|
||||
m_edit_symbols->setDefaultDropAction(Qt::CopyAction);
|
||||
m_edit_symbols->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
symbols_layout->addWidget(new QLabel(tr("Éléments :"), m_edit_symbols_box));
|
||||
symbols_layout->addWidget(m_edit_symbols_search);
|
||||
symbols_layout->addWidget(m_edit_symbols);
|
||||
|
||||
auto *symbols_timer = new QTimer(this);
|
||||
symbols_timer->setSingleShot(true);
|
||||
symbols_timer->setInterval(300);
|
||||
connect(m_edit_symbols_search, &QLineEdit::textChanged, this,
|
||||
[symbols_timer]() { symbols_timer->start(); });
|
||||
connect(symbols_timer, &QTimer::timeout, this, &ElementPickerPopup::runSymbolSearch);
|
||||
connect(m_edit_symbols, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
|
||||
m_edit_row->addItem(barItem(item->data(Qt::UserRole).toString(), true));
|
||||
});
|
||||
auto *defaults = new QPushButton(tr("Valeurs par défaut"), m_editor);
|
||||
auto *cancel = new QPushButton(tr("Annuler"), m_editor);
|
||||
auto *done = new QPushButton(tr("Terminé"), m_editor);
|
||||
done->setDefault(true);
|
||||
connect(defaults, &QPushButton::clicked, this, [this]() {
|
||||
fillCustomising(ShortcutBarSettings::defaultIds(m_context));
|
||||
});
|
||||
connect(cancel, &QPushButton::clicked, this, [this]() { finishCustomising(false); });
|
||||
connect(done, &QPushButton::clicked, this, [this]() { finishCustomising(true); });
|
||||
auto *editor_buttons = new QHBoxLayout();
|
||||
editor_buttons->addWidget(defaults);
|
||||
editor_buttons->addStretch();
|
||||
editor_buttons->addWidget(cancel);
|
||||
editor_buttons->addWidget(done);
|
||||
editor_layout->addWidget(help);
|
||||
editor_layout->addWidget(new QLabel(tr("Dans la barre :"), m_editor));
|
||||
editor_layout->addWidget(m_edit_row);
|
||||
auto *lists = new QHBoxLayout();
|
||||
auto *commands_column = new QVBoxLayout();
|
||||
commands_column->addWidget(new QLabel(tr("Autres commandes :"), m_editor));
|
||||
commands_column->addWidget(m_edit_available);
|
||||
lists->addLayout(commands_column);
|
||||
lists->addWidget(m_edit_symbols_box);
|
||||
editor_layout->addLayout(lists);
|
||||
editor_layout->addLayout(editor_buttons);
|
||||
m_editor->hide();
|
||||
|
||||
m_search = new QLineEdit(this);
|
||||
m_search->setPlaceholderText(tr("Rechercher un élément…"));
|
||||
m_search->setClearButtonEnabled(true);
|
||||
|
||||
m_model = new QStandardItemModel(this);
|
||||
m_view = new QListView(this);
|
||||
m_view->setModel(m_model);
|
||||
m_view->setIconSize(QSize(40, 40));
|
||||
m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
||||
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
m_view->setMinimumHeight(260);
|
||||
|
||||
m_hint = new QLabel(tr("Entrée pour insérer · Échap pour fermer"), this);
|
||||
m_hint->setEnabled(false);
|
||||
//Wrapped rather than cut off when the bar is made narrow
|
||||
m_hint->setWordWrap(true);
|
||||
|
||||
//The bar's width is the user's: dragging the grip wraps the tiles
|
||||
//onto more rows, and the width is kept for next time
|
||||
m_grip = new QSizeGrip(this);
|
||||
m_grip->setToolTip(tr("Glisser pour changer la largeur de la barre"));
|
||||
m_grip->installEventFilter(this);
|
||||
auto *hint_row = new QHBoxLayout();
|
||||
hint_row->addWidget(m_hint, 1);
|
||||
hint_row->addWidget(m_grip, 0, Qt::AlignBottom | Qt::AlignRight);
|
||||
|
||||
layout->addWidget(m_commands);
|
||||
layout->addWidget(m_search);
|
||||
layout->addWidget(m_view);
|
||||
layout->addLayout(hint_row);
|
||||
layout->addWidget(m_editor);
|
||||
|
||||
//Search as you type, after a short idle: shorter than the dock's
|
||||
//500 ms, since the popup is opened and closed in a few seconds, but
|
||||
//the collection is large enough that every keystroke would show.
|
||||
auto *timer = new QTimer(this);
|
||||
timer->setSingleShot(true);
|
||||
timer->setInterval(300);
|
||||
connect(m_search, &QLineEdit::textChanged, this,
|
||||
[timer]() { timer->start(); });
|
||||
connect(timer, &QTimer::timeout, this, &ElementPickerPopup::runSearch);
|
||||
|
||||
connect(m_view, &QListView::doubleClicked, this,
|
||||
[this](const QModelIndex &) { chooseCurrent(); });
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::popUpAt
|
||||
Show the picker at @a global_pos, kept on screen, with the search field
|
||||
focused and any previous query cleared.
|
||||
@param global_pos
|
||||
*/
|
||||
void ElementPickerPopup::popUpAt(const QPoint &global_pos)
|
||||
{
|
||||
m_bar_mode = false;
|
||||
setCommands({});
|
||||
show(global_pos);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::popUpShortcutBar
|
||||
Show the picker at @a global_pos with the shortcut bar's commands for
|
||||
@a context above it.
|
||||
@param global_pos
|
||||
@param context
|
||||
*/
|
||||
void ElementPickerPopup::popUpShortcutBar(const QPoint &global_pos,
|
||||
ShortcutBarSettings::Context context)
|
||||
{
|
||||
m_bar_mode = true;
|
||||
m_context = context;
|
||||
setCommands(ShortcutBarSettings::ids(context));
|
||||
show(global_pos);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::commandAction
|
||||
@return the action registered under @a id by the window this popup
|
||||
belongs to, or nullptr
|
||||
*/
|
||||
QAction *ElementPickerPopup::commandAction(const QString &id) const
|
||||
{
|
||||
return ShortcutManager::instance().action(id, parentWidget());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::show
|
||||
Reset the search and show at @a global_pos, kept on screen.
|
||||
*/
|
||||
void ElementPickerPopup::show(const QPoint &global_pos)
|
||||
{
|
||||
m_search->clear();
|
||||
m_model->clear();
|
||||
showPalette();
|
||||
|
||||
keepOnScreen(global_pos);
|
||||
QFrame::show();
|
||||
m_search->setFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::keepOnScreen
|
||||
Fit the popup to its contents and move it to @a global_pos, kept fully on
|
||||
the screen that point is on: opening at the cursor near a right or bottom
|
||||
edge would otherwise push it off.
|
||||
*/
|
||||
void ElementPickerPopup::keepOnScreen(const QPoint &global_pos)
|
||||
{
|
||||
adjustSize();
|
||||
//The sizes the user gave the customising window and the bar
|
||||
if (m_customising) {
|
||||
const QSize size = ShortcutBarSettings::editorSize();
|
||||
if (size.isValid()) {
|
||||
resize(size.expandedTo(minimumSizeHint()));
|
||||
}
|
||||
} else if (m_bar_mode && ShortcutBarSettings::barWidth() > 0) {
|
||||
resize(qMax(ShortcutBarSettings::barWidth(), minimumSizeHint().width()),
|
||||
height());
|
||||
}
|
||||
QPoint pos = global_pos;
|
||||
|
||||
if (QScreen *screen = QGuiApplication::screenAt(global_pos)) {
|
||||
const QRect avail = screen->availableGeometry();
|
||||
pos.setX(qBound(avail.left(),
|
||||
pos.x(), avail.right() - width()));
|
||||
pos.setY(qBound(avail.top(),
|
||||
pos.y(), avail.bottom() - height()));
|
||||
}
|
||||
|
||||
move(pos);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::setCommands
|
||||
Show @a ids as a row of buttons above the search field, or hide the row
|
||||
when there are none. A disabled command keeps its place, greyed out, so
|
||||
the row looks the same every time for a given selection. An id no live
|
||||
action carries, or a pinned element that no longer exists, is skipped.
|
||||
|
||||
Clicking a button closes the picker first, then triggers the action: a
|
||||
command such as "add a line" starts a mode on the folio, which needs the
|
||||
focus the popup holds. A pinned element is placed, as if chosen from the
|
||||
picker.
|
||||
@param ids : command ids and pinned elements' collection paths
|
||||
*/
|
||||
void ElementPickerPopup::setCommands(const QStringList &ids)
|
||||
{
|
||||
while (QLayoutItem *item = m_commands_layout->takeAt(0)) {
|
||||
delete item->widget();
|
||||
delete item;
|
||||
}
|
||||
qDeleteAll(m_tiles);
|
||||
m_tiles.clear();
|
||||
m_tile_rows = new QVBoxLayout();
|
||||
m_tile_rows->setSpacing(2);
|
||||
m_commands_layout->addLayout(m_tile_rows);
|
||||
|
||||
for (const QString &id : ids)
|
||||
{
|
||||
if (ShortcutBarSettings::isElement(id))
|
||||
{
|
||||
const ElementsLocation location(id);
|
||||
if (!location.exist()) {
|
||||
continue;
|
||||
}
|
||||
auto *button = new QToolButton(m_commands);
|
||||
button->setAutoRaise(true);
|
||||
button->setIconSize(QSize(24, 24));
|
||||
button->setIcon(location.icon());
|
||||
button->setToolTip(location.name());
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
connect(button, &QToolButton::clicked, this, [this, location]() {
|
||||
hide();
|
||||
emit elementChosen(location);
|
||||
});
|
||||
m_tiles << button;
|
||||
continue;
|
||||
}
|
||||
|
||||
QAction *action = commandAction(id);
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
auto *button = new QToolButton(m_commands);
|
||||
button->setAutoRaise(true);
|
||||
button->setIconSize(QSize(24, 24));
|
||||
const QString text = action->text().remove(QLatin1Char('&'));
|
||||
if (action->icon().isNull()) {
|
||||
button->setText(text);
|
||||
button->setToolButtonStyle(Qt::ToolButtonTextOnly);
|
||||
} else {
|
||||
button->setIcon(action->icon());
|
||||
}
|
||||
const QKeySequence key = action->shortcut();
|
||||
button->setToolTip(key.isEmpty()
|
||||
? text
|
||||
: QStringLiteral("%1 (%2)").arg(
|
||||
text, key.toString(QKeySequence::NativeText)));
|
||||
button->setEnabled(action->isEnabled());
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
connect(button, &QToolButton::clicked, this, [this, action]() {
|
||||
hide();
|
||||
action->trigger();
|
||||
});
|
||||
m_tiles << button;
|
||||
}
|
||||
m_commands_layout->addStretch();
|
||||
if (m_bar_mode)
|
||||
{
|
||||
auto *customise = new QToolButton(m_commands);
|
||||
customise->setAutoRaise(true);
|
||||
customise->setText(QStringLiteral("…"));
|
||||
customise->setToolTip(tr("Personnaliser la barre…"));
|
||||
customise->setFocusPolicy(Qt::NoFocus);
|
||||
connect(customise, &QToolButton::clicked, this, &ElementPickerPopup::startCustomising);
|
||||
m_commands_layout->addWidget(customise, 0, Qt::AlignTop);
|
||||
}
|
||||
layoutTiles(ShortcutBarSettings::barWidth());
|
||||
m_grip->setVisible(m_bar_mode);
|
||||
//An empty bar still shows, so it can be customised back
|
||||
m_commands->setVisible(m_bar_mode || !ids.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::layoutTiles
|
||||
Put the bar's tiles in rows no wider than the bar at @a bar_width, in
|
||||
their order, as many per row as fit. A @a bar_width of 0 or less, the
|
||||
width the user never set, keeps them on one row.
|
||||
*/
|
||||
void ElementPickerPopup::layoutTiles(int bar_width)
|
||||
{
|
||||
if (!m_tile_rows) {
|
||||
return;
|
||||
}
|
||||
//Emptying a row layout leaves its buttons alone; they go into the
|
||||
//new rows below
|
||||
while (QLayoutItem *item = m_tile_rows->takeAt(0)) {
|
||||
delete item->layout();
|
||||
}
|
||||
|
||||
//Room left beside the tiles: the popup's margins and the "…" button
|
||||
const int limit = bar_width > 0
|
||||
? bar_width - 12 - 30
|
||||
: std::numeric_limits<int>::max();
|
||||
QHBoxLayout *row = nullptr;
|
||||
int used = 0;
|
||||
for (QToolButton *tile : std::as_const(m_tiles))
|
||||
{
|
||||
const int w = tile->sizeHint().width() + m_tile_rows->spacing();
|
||||
if (!row || (used + w > limit && used > 0)) {
|
||||
row = new QHBoxLayout();
|
||||
row->setSpacing(2);
|
||||
row->addStretch();
|
||||
m_tile_rows->addLayout(row);
|
||||
used = 0;
|
||||
}
|
||||
//Before the stretch, which stays last so the row is left-aligned
|
||||
row->insertWidget(row->count() - 1, tile);
|
||||
used += w;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::eventFilter
|
||||
Drive the size grip by hand. QSizeGrip asks the window manager to do the
|
||||
resize, and a popup is a window the window manager does not manage, so
|
||||
on X11 nothing would happen. Only the width follows the mouse: the tiles
|
||||
re-flow into rows as it changes, the height follows the rows, and the
|
||||
width is saved when the grip is let go.
|
||||
*/
|
||||
bool ElementPickerPopup::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched != m_grip) {
|
||||
return QFrame::eventFilter(watched, event);
|
||||
}
|
||||
switch (event->type())
|
||||
{
|
||||
case QEvent::MouseButtonPress: {
|
||||
auto *me = static_cast<QMouseEvent *>(event);
|
||||
if (me->button() != Qt::LeftButton) {
|
||||
break;
|
||||
}
|
||||
m_grip_active = true;
|
||||
m_grip_press_x = me->globalPosition().toPoint().x();
|
||||
m_grip_press_width = width();
|
||||
return true;
|
||||
}
|
||||
case QEvent::MouseMove: {
|
||||
if (!m_grip_active) {
|
||||
break;
|
||||
}
|
||||
auto *me = static_cast<QMouseEvent *>(event);
|
||||
const int w = qMax(minimumWidth(), m_grip_press_width
|
||||
+ me->globalPosition().toPoint().x() - m_grip_press_x);
|
||||
layoutTiles(w);
|
||||
layout()->activate();
|
||||
resize(w, sizeHint().height());
|
||||
return true;
|
||||
}
|
||||
case QEvent::MouseButtonRelease:
|
||||
if (!m_grip_active) {
|
||||
break;
|
||||
}
|
||||
m_grip_active = false;
|
||||
ShortcutBarSettings::setBarWidth(width());
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return QFrame::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::barItem
|
||||
@return a list item for @a id, a command or a pinned element: icon only,
|
||||
for the bar row, or icon and text, for the other lists. The id is in
|
||||
UserRole.
|
||||
*/
|
||||
QListWidgetItem *ElementPickerPopup::barItem(const QString &id, bool icon_only) const
|
||||
{
|
||||
QString text = id;
|
||||
QIcon icon;
|
||||
if (ShortcutBarSettings::isElement(id)) {
|
||||
const ElementsLocation location(id);
|
||||
if (location.exist()) {
|
||||
text = location.name();
|
||||
icon = location.icon();
|
||||
}
|
||||
} else if (QAction *action = commandAction(id)) {
|
||||
text = action->text().remove(QLatin1Char('&'));
|
||||
icon = action->icon();
|
||||
}
|
||||
auto *item = new QListWidgetItem();
|
||||
item->setData(Qt::UserRole, id);
|
||||
item->setToolTip(text);
|
||||
if (!icon.isNull()) {
|
||||
item->setIcon(icon);
|
||||
}
|
||||
if (!icon_only || item->icon().isNull()) {
|
||||
item->setText(text);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::startCustomising
|
||||
Turn the bar into a small window holding the two lists. A Qt::Popup
|
||||
closes as soon as the mouse is pressed outside it and keeps the mouse
|
||||
grab during a drag, so the window is re-shown as a tool window for the
|
||||
time of the edit.
|
||||
*/
|
||||
void ElementPickerPopup::startCustomising()
|
||||
{
|
||||
const QPoint where = pos();
|
||||
m_customising = true;
|
||||
hide();
|
||||
setWindowFlags(Qt::Tool);
|
||||
setWindowTitle(tr("Personnaliser la barre de raccourcis : %1")
|
||||
.arg(ShortcutBarSettings::title(m_context)));
|
||||
|
||||
fillCustomising(ShortcutBarSettings::ids(m_context));
|
||||
m_commands->hide();
|
||||
setPickerVisible(false);
|
||||
m_grip->hide();
|
||||
//Elements are pinned to the empty-folio bar only: with something
|
||||
//selected, the bar is for acting on it, not for adding more
|
||||
m_edit_symbols_search->clear();
|
||||
m_edit_symbols->clear();
|
||||
m_edit_symbols_box->setVisible(m_context == ShortcutBarSettings::Canvas);
|
||||
m_editor->show();
|
||||
|
||||
keepOnScreen(where);
|
||||
QFrame::show();
|
||||
activateWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::fillCustomising
|
||||
Put @a ids on the bar row, and every other available command in the
|
||||
list below it.
|
||||
*/
|
||||
void ElementPickerPopup::fillCustomising(const QStringList &ids)
|
||||
{
|
||||
m_edit_row->clear();
|
||||
m_edit_available->clear();
|
||||
for (const QString &id : ids) {
|
||||
m_edit_row->addItem(barItem(id, true));
|
||||
}
|
||||
for (const QString &id : ShortcutBarSettings::availableIds()) {
|
||||
if (!ids.contains(id)) {
|
||||
m_edit_available->addItem(barItem(id, false));
|
||||
}
|
||||
}
|
||||
m_edit_available->sortItems();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::runSymbolSearch
|
||||
Fill the customising window's element list with the ranked hits for its
|
||||
search field. Elements embedded in a project are left out: their path
|
||||
names the project as it is loaded now, so it would not find them again
|
||||
in a later session.
|
||||
*/
|
||||
void ElementPickerPopup::runSymbolSearch()
|
||||
{
|
||||
m_edit_symbols->clear();
|
||||
if (!m_source) {
|
||||
return;
|
||||
}
|
||||
const QVector<ElementSearchHit> hits =
|
||||
m_source->rankedSearch(m_edit_symbols_search->text());
|
||||
for (const ElementSearchHit &hit : hits)
|
||||
{
|
||||
if (ElementsLocation(hit.path).isProject()) {
|
||||
continue;
|
||||
}
|
||||
auto *item = new QListWidgetItem(hit.icon, hit.name);
|
||||
item->setData(Qt::UserRole, hit.path);
|
||||
item->setToolTip(QStringLiteral("%1\n%2").arg(hit.name, hit.folder));
|
||||
m_edit_symbols->addItem(item);
|
||||
if (m_edit_symbols->count() >= max_palette_entries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::finishCustomising
|
||||
Leave the edit, saving the bar row when @a save, and show the bar again
|
||||
at the cursor so the result can be seen and used straight away. The
|
||||
window's size is kept either way.
|
||||
*/
|
||||
void ElementPickerPopup::finishCustomising(bool save)
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
QStringList ids;
|
||||
for (int i = 0 ; i < m_edit_row->count() ; ++i) {
|
||||
ids << m_edit_row->item(i)->data(Qt::UserRole).toString();
|
||||
}
|
||||
ShortcutBarSettings::setIds(m_context, ids);
|
||||
}
|
||||
|
||||
ShortcutBarSettings::setEditorSize(size());
|
||||
m_customising = false;
|
||||
hide();
|
||||
m_editor->hide();
|
||||
setPickerVisible(true);
|
||||
setWindowFlags(Qt::Popup);
|
||||
setWindowTitle(QString());
|
||||
//At the cursor, like any other opening of the bar
|
||||
popUpShortcutBar(QCursor::pos(), m_context);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::closeEvent
|
||||
Closing the customising window from its title bar cancels the edit,
|
||||
like the Annuler button, rather than leaving the bar half-edited.
|
||||
*/
|
||||
void ElementPickerPopup::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
if (m_customising) {
|
||||
event->ignore();
|
||||
finishCustomising(false);
|
||||
return;
|
||||
}
|
||||
QFrame::closeEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::setPickerVisible
|
||||
Show or hide the search field, the results and the hint line.
|
||||
*/
|
||||
void ElementPickerPopup::setPickerVisible(bool visible)
|
||||
{
|
||||
m_search->setVisible(visible);
|
||||
m_view->setVisible(visible);
|
||||
m_hint->setVisible(visible);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::runSearch
|
||||
*/
|
||||
void ElementPickerPopup::runSearch()
|
||||
{
|
||||
m_model->clear();
|
||||
if (!m_source) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_search->text().isEmpty()) {
|
||||
const bool was_hidden = m_view->isHidden();
|
||||
showPalette();
|
||||
if (was_hidden != m_view->isHidden()) {
|
||||
keepOnScreen(pos());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_view->isHidden()) {
|
||||
m_view->show();
|
||||
keepOnScreen(pos());
|
||||
}
|
||||
m_palette_mode = false;
|
||||
m_view->setViewMode(QListView::ListMode);
|
||||
m_view->setGridSize(QSize());
|
||||
m_view->setIconSize(QSize(40, 40));
|
||||
|
||||
const QVector<ElementSearchHit> hits = m_source->rankedSearch(m_search->text());
|
||||
for (const ElementSearchHit &hit : hits)
|
||||
{
|
||||
auto *item = new QStandardItem(hit.name);
|
||||
item->setIcon(hit.icon);
|
||||
item->setEditable(false);
|
||||
item->setToolTip(hit.folder);
|
||||
item->setData(hit.path, Qt::UserRole + 2);
|
||||
m_model->appendRow(item);
|
||||
}
|
||||
|
||||
//Preselect the best hit so Enter works straight from the search field
|
||||
//without arrowing down first -- that is the whole point of the popup.
|
||||
if (m_model->rowCount()) {
|
||||
m_view->setCurrentIndex(m_model->index(0, 0));
|
||||
}
|
||||
m_hint->setText(hits.isEmpty()
|
||||
? tr("Aucun résultat")
|
||||
: tr("Entrée pour insérer · Échap pour fermer"));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::chooseCurrent
|
||||
Emit the highlighted element and close.
|
||||
*/
|
||||
void ElementPickerPopup::chooseCurrent()
|
||||
{
|
||||
const QModelIndex index = m_view->currentIndex();
|
||||
if (!index.isValid()) {
|
||||
return;
|
||||
}
|
||||
const QString path = index.data(Qt::UserRole + 2).toString();
|
||||
if (path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ElementsLocation location(path);
|
||||
if (!location.exist()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Close before emitting: placement mode wants the focus, and a popup
|
||||
//still up would keep the grab.
|
||||
hide();
|
||||
emit elementChosen(location);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::keyPressEvent
|
||||
Drive the list from the search field, so the hands never leave the
|
||||
keyboard: Up/Down move the selection, Enter places, Esc closes.
|
||||
@param event
|
||||
*/
|
||||
void ElementPickerPopup::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
switch (event->key())
|
||||
{
|
||||
case Qt::Key_Escape:
|
||||
if (m_customising) {
|
||||
finishCustomising(false);
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
return;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
if (m_customising && m_edit_symbols_search->hasFocus()) {
|
||||
//Enter in the element search pins the best hit rather
|
||||
//than closing the window. Searched now, not on the
|
||||
//timer, so a quick Enter after typing gets this text.
|
||||
runSymbolSearch();
|
||||
if (QListWidgetItem *item = m_edit_symbols->item(0)) {
|
||||
m_edit_row->addItem(barItem(item->data(Qt::UserRole).toString(), true));
|
||||
}
|
||||
} else if (m_customising) {
|
||||
finishCustomising(true);
|
||||
} else {
|
||||
chooseCurrent();
|
||||
}
|
||||
return;
|
||||
case Qt::Key_Down:
|
||||
case Qt::Key_Up:
|
||||
case Qt::Key_PageDown:
|
||||
case Qt::Key_PageUp:
|
||||
if (!m_customising && m_model->rowCount()) {
|
||||
//Forwarded rather than focus-switched, so typing carries
|
||||
//on going to the search field.
|
||||
QCoreApplication::sendEvent(m_view, event);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
QFrame::keyPressEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::showPalette
|
||||
With an empty search field, show the quick palette as an icon grid.
|
||||
|
||||
The palette is a folder, not a config file. QET already has a user-owned,
|
||||
drag-populated, icon-rendering collection -- so a palette is just a
|
||||
directory whose subfolders are categories and whose contents are entries.
|
||||
That gives set-up (drag into the folder), ordering (the 01_/02_ filename
|
||||
convention the shipped collection already uses), team sharing
|
||||
(companyElementsDir) and version control for free, with no schema, parser,
|
||||
merge rules or "reset to defaults" to build or maintain.
|
||||
|
||||
Only the path lives in QSettings, defaulting to the user's custom
|
||||
collection.
|
||||
*/
|
||||
void ElementPickerPopup::showPalette()
|
||||
{
|
||||
m_palette_mode = true;
|
||||
m_model->clear();
|
||||
|
||||
//Elements pinned to the bar replace the folder grid: the bar already
|
||||
//shows the user's shortlist, and a second one below it is noise.
|
||||
//The search still works; the list comes back as soon as one types.
|
||||
if (m_bar_mode && ShortcutBarSettings::hasElements(m_context)) {
|
||||
m_view->hide();
|
||||
m_hint->setText(tr("Tapez pour rechercher un élément · Échap pour fermer"));
|
||||
return;
|
||||
}
|
||||
m_view->show();
|
||||
|
||||
m_view->setViewMode(QListView::IconMode);
|
||||
m_view->setIconSize(QSize(48, 48));
|
||||
m_view->setGridSize(QSize(92, 84));
|
||||
m_view->setResizeMode(QListView::Adjust);
|
||||
m_view->setWordWrap(true);
|
||||
m_view->setMovement(QListView::Static);
|
||||
|
||||
QSettings settings;
|
||||
const QString path = settings.value(
|
||||
QStringLiteral("elementscollection/palette-path"),
|
||||
QETApp::customElementsDir()).toString();
|
||||
|
||||
const int count = loadPaletteDir(path, QString(), 0);
|
||||
|
||||
if (!count) {
|
||||
m_hint->setText(
|
||||
tr("Palette vide — glissez des éléments dans votre collection "
|
||||
"personnelle, ou tapez pour rechercher"));
|
||||
} else {
|
||||
m_hint->setText(tr("Entrée pour insérer · Échap pour fermer"));
|
||||
m_view->setCurrentIndex(m_model->index(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::loadPaletteDir
|
||||
Add every .elmt under @a dir_path to the grid, recursing into subfolders.
|
||||
@param dir_path
|
||||
@param prefix : folder path so far, shown as the entry's tooltip
|
||||
@param depth : recursion guard -- a palette is a shortlist, and the whole
|
||||
shipped collection would not be usable as a grid anyway
|
||||
@return how many entries were added
|
||||
*/
|
||||
int ElementPickerPopup::loadPaletteDir(const QString &dir_path,
|
||||
const QString &prefix, int depth)
|
||||
{
|
||||
if (depth > 3 || m_model->rowCount() >= max_palette_entries) {
|
||||
return 0;
|
||||
}
|
||||
QDir dir(dir_path);
|
||||
if (!dir.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
//Sorted by name, which is what makes the 01_/02_ filename convention
|
||||
//work as the ordering mechanism.
|
||||
const QFileInfoList entries = dir.entryInfoList(
|
||||
QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||
|
||||
for (const QFileInfo &info : entries)
|
||||
{
|
||||
if (m_model->rowCount() >= max_palette_entries) {
|
||||
break;
|
||||
}
|
||||
if (info.isDir()) {
|
||||
added += loadPaletteDir(info.absoluteFilePath(),
|
||||
prefix.isEmpty()
|
||||
? info.fileName()
|
||||
: prefix + QStringLiteral(" / ")
|
||||
+ info.fileName(),
|
||||
depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (info.suffix().compare(QStringLiteral("elmt"),
|
||||
Qt::CaseInsensitive) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ElementsLocation location(info.absoluteFilePath());
|
||||
if (!location.exist()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *item = new QStandardItem(location.name().isEmpty()
|
||||
? info.completeBaseName()
|
||||
: location.name());
|
||||
item->setIcon(location.icon());
|
||||
item->setEditable(false);
|
||||
item->setToolTip(prefix.isEmpty() ? info.completeBaseName() : prefix);
|
||||
item->setData(info.absoluteFilePath(), Qt::UserRole + 2);
|
||||
item->setTextAlignment(Qt::AlignHCenter | Qt::AlignTop);
|
||||
m_model->appendRow(item);
|
||||
++added;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef ELEMENTPICKERPOPUP_H
|
||||
#define ELEMENTPICKERPOPUP_H
|
||||
|
||||
#include "elementslocation.h"
|
||||
#include "../shortcutbarsettings.h"
|
||||
|
||||
#include <QFrame>
|
||||
|
||||
class ElementsCollectionWidget;
|
||||
class QLineEdit;
|
||||
class QListView;
|
||||
class QStandardItemModel;
|
||||
class QLabel;
|
||||
class QAction;
|
||||
class QHBoxLayout;
|
||||
class QListWidget;
|
||||
class QListWidgetItem;
|
||||
class QSizeGrip;
|
||||
class QToolButton;
|
||||
class QVBoxLayout;
|
||||
|
||||
/**
|
||||
@brief A cursor-anchored element picker.
|
||||
|
||||
Opens where the mouse is, focused on its search field: type to filter,
|
||||
Enter to place, Esc to close. It is a recall tool, as opposed to the
|
||||
Collections dock, which is a browsing tool.
|
||||
|
||||
It deliberately does not build its own ElementsCollectionModel. Loading the
|
||||
collection is already the slow part of startup and a second copy would
|
||||
double it, so the picker asks the dock's widget to run the query -- see
|
||||
ElementsCollectionWidget::rankedSearch(). That also means the two always
|
||||
agree on results and ranking.
|
||||
*/
|
||||
class ElementPickerPopup : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ElementPickerPopup(ElementsCollectionWidget *source,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
void popUpAt(const QPoint &global_pos);
|
||||
void popUpShortcutBar(const QPoint &global_pos,
|
||||
ShortcutBarSettings::Context context);
|
||||
|
||||
signals:
|
||||
/// Emitted when the user picks an element; the popup has closed
|
||||
void elementChosen(const ElementsLocation &location);
|
||||
|
||||
protected:
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void runSearch();
|
||||
void chooseCurrent();
|
||||
void showPalette();
|
||||
void show(const QPoint &global_pos);
|
||||
void setCommands(const QStringList &ids);
|
||||
void layoutTiles(int bar_width);
|
||||
QAction *commandAction(const QString &id) const;
|
||||
QListWidgetItem *barItem(const QString &id, bool icon_only) const;
|
||||
void startCustomising();
|
||||
void fillCustomising(const QStringList &ids);
|
||||
void finishCustomising(bool save);
|
||||
void runSymbolSearch();
|
||||
void setPickerVisible(bool visible);
|
||||
void keepOnScreen(const QPoint &global_pos);
|
||||
int loadPaletteDir(const QString &dir_path, const QString &prefix,
|
||||
int depth);
|
||||
|
||||
ElementsCollectionWidget *m_source = nullptr;
|
||||
QLineEdit *m_search = nullptr;
|
||||
QListView *m_view = nullptr;
|
||||
QStandardItemModel *m_model = nullptr;
|
||||
QLabel *m_hint = nullptr;
|
||||
QWidget *m_commands = nullptr;
|
||||
QHBoxLayout *m_commands_layout = nullptr;
|
||||
/// The bar's tiles, commands and elements, in order
|
||||
QList<QToolButton *> m_tiles;
|
||||
QVBoxLayout *m_tile_rows = nullptr;
|
||||
QSizeGrip *m_grip = nullptr;
|
||||
/// The size grip is being dragged, from this x at this width
|
||||
bool m_grip_active = false;
|
||||
int m_grip_press_x = 0;
|
||||
int m_grip_press_width = 0;
|
||||
/// Opened as the shortcut bar, as opposed to the plain picker
|
||||
bool m_bar_mode = false;
|
||||
ShortcutBarSettings::Context m_context = ShortcutBarSettings::Canvas;
|
||||
/// Customising the bar in place
|
||||
bool m_customising = false;
|
||||
QWidget *m_editor = nullptr;
|
||||
QListWidget *m_edit_row = nullptr;
|
||||
QListWidget *m_edit_available = nullptr;
|
||||
/// Element search, for pinning elements to the bar
|
||||
QWidget *m_edit_symbols_box = nullptr;
|
||||
QLineEdit *m_edit_symbols_search = nullptr;
|
||||
QListWidget *m_edit_symbols = nullptr;
|
||||
bool m_palette_mode = true;
|
||||
};
|
||||
|
||||
#endif // ELEMENTPICKERPOPUP_H
|
||||
@@ -35,6 +35,12 @@
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDesktopServices>
|
||||
#include <QSettings>
|
||||
#include <QShortcut>
|
||||
#include <QListView>
|
||||
#include <QStandardItemModel>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
@@ -49,6 +55,32 @@
|
||||
#include <QStatusBar>
|
||||
#include <QLineEdit>
|
||||
|
||||
namespace {
|
||||
/**
|
||||
@brief The SearchResultsView class
|
||||
The flat list of search results. Rows are not backed by the collection
|
||||
model, so the drag is built here from the path each row carries, with
|
||||
the same content and pixmap as a drag from the tree.
|
||||
*/
|
||||
class SearchResultsView : public QListView
|
||||
{
|
||||
public:
|
||||
using QListView::QListView;
|
||||
|
||||
protected:
|
||||
void startDrag(Qt::DropActions supportedActions) override
|
||||
{
|
||||
const QString path =
|
||||
currentIndex().data(Qt::UserRole + 2).toString();
|
||||
if (path.isEmpty()) {
|
||||
QListView::startDrag(supportedActions);
|
||||
return;
|
||||
}
|
||||
ElementsTreeView::execElementDrag(this, ElementsLocation(path));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::ElementsCollectionWidget
|
||||
Default constructor.
|
||||
@@ -217,8 +249,26 @@ void ElementsCollectionWidget::setUpWidget()
|
||||
m_tab_widget->addTab(m_tree_view, tr("Collections"));
|
||||
m_tab_widget->addTab(m_macros_tree_view, tr("Modèles"));
|
||||
|
||||
//Flat ranked search results.
|
||||
//The tree search hides non-matching rows, so hits stay scattered
|
||||
//through five levels of expanded folders -- searching "diode" leaves
|
||||
//you scrolling a tree to find them. This shows the same matches as a
|
||||
//ranked list instead, and takes the tab widget's place while a search
|
||||
//is active.
|
||||
m_search_model = new QStandardItemModel(this);
|
||||
m_search_results = new SearchResultsView(this);
|
||||
m_search_results->setModel(m_search_model);
|
||||
m_search_results->setDragDropMode(QAbstractItemView::DragOnly);
|
||||
m_search_results->setIconSize(QSize(50, 50));
|
||||
m_search_results->setUniformItemSizes(false);
|
||||
m_search_results->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
||||
m_search_results->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
m_search_results->setMouseTracking(true);
|
||||
m_search_results->hide();
|
||||
|
||||
m_main_vlayout->addWidget(m_search_field);
|
||||
m_main_vlayout->addWidget(m_tab_widget);
|
||||
m_main_vlayout->addWidget(m_search_results);
|
||||
|
||||
m_progress_bar = new QProgressBar(this);
|
||||
m_progress_bar->setFormat(QObject::tr("chargement %p% (%v sur %m)"));
|
||||
@@ -266,15 +316,7 @@ void ElementsCollectionWidget::setUpConnection()
|
||||
this, &ElementsCollectionWidget::dirProperties);
|
||||
|
||||
connect(m_tree_view, &QTreeView::doubleClicked,
|
||||
[this](const QModelIndex &index)
|
||||
{
|
||||
this->m_index_at_context_menu = index ;
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (eci && eci->collectionPath().endsWith(".qetmak")) {
|
||||
return; // Do nothing on double click for macros
|
||||
}
|
||||
this->editElement();
|
||||
});
|
||||
[this](const QModelIndex &index) { this->activateIndex(index); });
|
||||
|
||||
connect(m_tree_view, &QTreeView::entered,
|
||||
[this] (const QModelIndex &index) {
|
||||
@@ -284,19 +326,58 @@ void ElementsCollectionWidget::setUpConnection()
|
||||
qde->statusBar()->showMessage(eci->localName());
|
||||
});
|
||||
|
||||
//Enter on the highlighted item does the same as a double click, so a
|
||||
//run of elements can be placed without leaving the keyboard. Bound as
|
||||
//a shortcut on the view rather than by reimplementing keyPressEvent,
|
||||
//which would mean subclassing ElementsTreeView for one key.
|
||||
for (const auto key : {Qt::Key_Return, Qt::Key_Enter}) {
|
||||
auto *sc = new QShortcut(QKeySequence(key), m_tree_view);
|
||||
sc->setContext(Qt::WidgetShortcut);
|
||||
connect(sc, &QShortcut::activated, this, [this]() {
|
||||
this->activateIndex(m_tree_view->currentIndex());
|
||||
});
|
||||
}
|
||||
|
||||
//The flat results list carries the collection path directly, so it
|
||||
//does not go through activateIndex() -- there is no tree index behind
|
||||
//a row to look an ElementCollectionItem up from.
|
||||
auto place_from_results = [this](const QModelIndex &index) {
|
||||
const QString path = index.data(Qt::UserRole + 2).toString();
|
||||
if (path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ElementsLocation location(path);
|
||||
if (location.exist()) {
|
||||
emit insertElementRequested(location);
|
||||
}
|
||||
};
|
||||
connect(m_search_results, &QListView::doubleClicked, this, place_from_results);
|
||||
for (const auto key : {Qt::Key_Return, Qt::Key_Enter}) {
|
||||
auto *sc = new QShortcut(QKeySequence(key), m_search_results);
|
||||
sc->setContext(Qt::WidgetShortcut);
|
||||
connect(sc, &QShortcut::activated, this, [this, place_from_results]() {
|
||||
place_from_results(m_search_results->currentIndex());
|
||||
});
|
||||
}
|
||||
//Down from the search field moves into the results, so the whole
|
||||
//type-then-place run happens without touching the mouse.
|
||||
auto *to_results = new QShortcut(QKeySequence(Qt::Key_Down), m_search_field);
|
||||
to_results->setContext(Qt::WidgetShortcut);
|
||||
connect(to_results, &QShortcut::activated, this, [this]() {
|
||||
if (!m_search_results->isVisible() || !m_search_model->rowCount()) {
|
||||
return;
|
||||
}
|
||||
m_search_results->setFocus();
|
||||
if (!m_search_results->currentIndex().isValid()) {
|
||||
m_search_results->setCurrentIndex(m_search_model->index(0, 0));
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_macros_tree_view, &QTreeView::customContextMenuRequested,
|
||||
this, &ElementsCollectionWidget::customContextMenu);
|
||||
|
||||
connect(m_macros_tree_view, &QTreeView::doubleClicked,
|
||||
[this](const QModelIndex &index)
|
||||
{
|
||||
this->m_index_at_context_menu = index ;
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (eci && eci->collectionPath().endsWith(".qetmak")) {
|
||||
return; // Do nothing on double click for macros
|
||||
}
|
||||
this->editElement();
|
||||
});
|
||||
[this](const QModelIndex &index) { this->activateIndex(index); });
|
||||
|
||||
connect(m_macros_tree_view, &QTreeView::entered,
|
||||
[this] (const QModelIndex &index) {
|
||||
@@ -405,6 +486,60 @@ void ElementsCollectionWidget::openDir()
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::activateIndex
|
||||
What a double click (or Enter) on @a index does.
|
||||
|
||||
Historically this opened the element editor, which is the slowest action
|
||||
available on a symbol you are most likely about to place. Placing is now
|
||||
the default and editing has moved to the context menu, where it already
|
||||
was. The old behaviour is preserved behind a preference for anyone who
|
||||
relies on it.
|
||||
@param index
|
||||
*/
|
||||
void ElementsCollectionWidget::activateIndex(const QModelIndex &index)
|
||||
{
|
||||
m_index_at_context_menu = index;
|
||||
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (!eci) {
|
||||
return;
|
||||
}
|
||||
//Macros are placed, never edited, and were already skipped here.
|
||||
const bool is_macro = eci->collectionPath().endsWith(".qetmak");
|
||||
|
||||
QSettings settings;
|
||||
const bool insert = settings.value(
|
||||
QStringLiteral("elementscollection/double-click-inserts"), true).toBool();
|
||||
|
||||
if (insert) {
|
||||
insertCurrentElement();
|
||||
return;
|
||||
}
|
||||
if (!is_macro) {
|
||||
editElement();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::insertCurrentElement
|
||||
Ask for the current item to be placed on the folio.
|
||||
*/
|
||||
void ElementsCollectionWidget::insertCurrentElement()
|
||||
{
|
||||
ElementCollectionItem *eci =
|
||||
elementCollectionItemForIndex(m_index_at_context_menu);
|
||||
if (!(eci && eci->isElement())) {
|
||||
return;
|
||||
}
|
||||
|
||||
ElementsLocation location(eci->collectionPath());
|
||||
if (!location.exist()) {
|
||||
return;
|
||||
}
|
||||
emit insertElementRequested(location);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::editElement
|
||||
Edit the element represented by the current selected item
|
||||
@@ -415,10 +550,19 @@ void ElementsCollectionWidget::editElement()
|
||||
|
||||
if ( !(eci && eci->isElement()) ) return;
|
||||
|
||||
// Prevent the element editor from opening for macros
|
||||
if (eci->collectionPath().endsWith(".qetmak")) return;
|
||||
editLocation(ElementsLocation(eci->collectionPath()));
|
||||
}
|
||||
|
||||
ElementsLocation location(eci->collectionPath());
|
||||
/**
|
||||
@brief ElementsCollectionWidget::editLocation
|
||||
Open the element editor on @a location. Macros have no editor and are
|
||||
ignored.
|
||||
@param location
|
||||
*/
|
||||
void ElementsCollectionWidget::editLocation(const ElementsLocation &location)
|
||||
{
|
||||
// Prevent the element editor from opening for macros
|
||||
if (!location.exist() || location.path().endsWith(".qetmak")) return;
|
||||
|
||||
QETApp *app = QETApp::instance();
|
||||
app->openElementLocations(QList<ElementsLocation>() << location);
|
||||
@@ -932,6 +1076,7 @@ void ElementsCollectionWidget::search()
|
||||
//Reset the search
|
||||
if (text.isEmpty())
|
||||
{
|
||||
clearFlatResults();
|
||||
QModelIndex current_index = m_tree_view->currentIndex();
|
||||
m_tree_view->reset();
|
||||
|
||||
@@ -958,22 +1103,154 @@ void ElementsCollectionWidget::search()
|
||||
return;
|
||||
}
|
||||
|
||||
hideCollection(true);
|
||||
const QStringList text_list = text.split("+", Qt::SkipEmptyParts);
|
||||
QModelIndexList match_index;
|
||||
for (QString txt : text_list) {
|
||||
match_index << m_model->match(m_showed_index.isValid()
|
||||
? m_model->index(0,0,m_showed_index)
|
||||
: m_model->index(0,0),
|
||||
Qt::UserRole+1,
|
||||
QVariant(txt),
|
||||
-1,
|
||||
Qt::MatchContains
|
||||
| Qt::MatchRecursive);
|
||||
showFlatResults(rankedSearch(text, m_showed_index));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::rankedSearch
|
||||
Run the collection search for @a text and return the element hits, best
|
||||
first. The hits carry no model index, so a list that is not backed by the
|
||||
collection model can show them too.
|
||||
@param text : search text, "+" separating terms
|
||||
@param within : restrict the search to this item's children, or the
|
||||
whole collection when invalid
|
||||
@return ranked hits, best first
|
||||
*/
|
||||
QVector<ElementSearchHit> ElementsCollectionWidget::rankedSearch(
|
||||
const QString &text,
|
||||
const QModelIndex &within)
|
||||
{
|
||||
QVector<ElementSearchHit> hits;
|
||||
if (!m_model || text.size() < 3) {
|
||||
return hits;
|
||||
}
|
||||
|
||||
for(QModelIndex index : match_index)
|
||||
showAndExpandItem(index);
|
||||
const QStringList terms = text.split("+", Qt::SkipEmptyParts);
|
||||
QModelIndexList matches;
|
||||
for (const QString &term : terms) {
|
||||
matches << m_model->match(within.isValid()
|
||||
? m_model->index(0, 0, within)
|
||||
: m_model->index(0, 0),
|
||||
Qt::UserRole+1,
|
||||
QVariant(term),
|
||||
-1,
|
||||
Qt::MatchContains
|
||||
| Qt::MatchRecursive);
|
||||
}
|
||||
|
||||
const QString needle = terms.value(0).toLower();
|
||||
QVector<QPair<int, ElementSearchHit>> scored;
|
||||
QSet<QString> seen;
|
||||
|
||||
for (const QModelIndex &index : std::as_const(matches))
|
||||
{
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (!(eci && eci->isElement())) {
|
||||
continue;
|
||||
}
|
||||
const QString path = eci->collectionPath();
|
||||
//match() is run once per "+"-separated term, so the same element
|
||||
//can arrive several times.
|
||||
if (path.isEmpty() || seen.contains(path)) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(path);
|
||||
|
||||
ElementSearchHit hit;
|
||||
hit.path = path;
|
||||
hit.name = index.data(Qt::DisplayRole).toString();
|
||||
hit.icon = qvariant_cast<QIcon>(index.data(Qt::DecorationRole));
|
||||
//Where it lives, so two similarly-named symbols are tellable apart
|
||||
QStringList parts;
|
||||
for (QModelIndex p = index.parent(); p.isValid(); p = p.parent()) {
|
||||
parts.prepend(p.data(Qt::DisplayRole).toString());
|
||||
}
|
||||
hit.folder = parts.join(QStringLiteral(" / "));
|
||||
|
||||
const QString hay = index.data(Qt::UserRole + 1).toString().toLower();
|
||||
scored.append({rankMatch(needle, hit.name, hay), hit});
|
||||
}
|
||||
|
||||
std::stable_sort(scored.begin(), scored.end(),
|
||||
[](const QPair<int, ElementSearchHit> &a,
|
||||
const QPair<int, ElementSearchHit> &b) {
|
||||
return a.first > b.first;
|
||||
});
|
||||
hits.reserve(scored.size());
|
||||
for (const auto &p : std::as_const(scored)) {
|
||||
hits.append(p.second);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::rankMatch
|
||||
Score a hit so the list can be ordered by how well it matches.
|
||||
|
||||
The tree search treats every hit equally, which is fine when they stay in
|
||||
place but useless in a ranked list: typing "diode" should not put an
|
||||
element whose *description* mentions diodes above one actually called
|
||||
"Diode". Name beats element-info field, earlier beats later, shorter beats
|
||||
longer.
|
||||
@param needle : lower-cased search text
|
||||
@param name : the element's display name
|
||||
@param haystack : the full indexed string (name + every info field)
|
||||
@return a score, higher is better
|
||||
*/
|
||||
int ElementsCollectionWidget::rankMatch(const QString &needle,
|
||||
const QString &name,
|
||||
const QString &haystack)
|
||||
{
|
||||
const QString n = name.toLower();
|
||||
int score = 0;
|
||||
|
||||
if (n == needle) {
|
||||
score = 1000;
|
||||
} else if (n.startsWith(needle)) {
|
||||
score = 800;
|
||||
} else if (n.contains(needle)) {
|
||||
score = 600 - qMin(n.indexOf(needle), 99);
|
||||
} else if (haystack.contains(needle)) {
|
||||
//Matched only on an info field -- manufacturer, reference,
|
||||
//description. Still a real hit, just a weaker one.
|
||||
score = 300;
|
||||
}
|
||||
//Among equally-placed hits prefer the shorter name: "Diode" over
|
||||
//"Diode Zener bidirectional".
|
||||
return score - qMin(n.size(), 99);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::showFlatResults
|
||||
Replace the tree with a flat list of @a hits, in the order given.
|
||||
@param hits : from rankedSearch()
|
||||
*/
|
||||
void ElementsCollectionWidget::showFlatResults(const QVector<ElementSearchHit> &hits)
|
||||
{
|
||||
m_search_model->clear();
|
||||
|
||||
for (const ElementSearchHit &hit : hits)
|
||||
{
|
||||
auto *item = new QStandardItem(hit.icon, hit.name);
|
||||
item->setEditable(false);
|
||||
item->setToolTip(hit.folder);
|
||||
item->setData(hit.path, Qt::UserRole + 2);
|
||||
m_search_model->appendRow(item);
|
||||
}
|
||||
|
||||
m_tab_widget->hide();
|
||||
m_search_results->show();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::clearFlatResults
|
||||
Put the tree back when the search field is emptied.
|
||||
*/
|
||||
void ElementsCollectionWidget::clearFlatResults()
|
||||
{
|
||||
m_search_model->clear();
|
||||
m_search_results->hide();
|
||||
m_tab_widget->show();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,22 @@ class ElementsTreeView;
|
||||
and all action needed to use this widget.
|
||||
This is the element collection widget used in the diagram editor.
|
||||
*/
|
||||
class QListView;
|
||||
class QStandardItemModel;
|
||||
|
||||
/**
|
||||
@brief One ranked hit from a collection search.
|
||||
Carries everything a result row needs, so a list that is not backed by
|
||||
the collection model can show it.
|
||||
*/
|
||||
struct ElementSearchHit
|
||||
{
|
||||
QString path; ///< collection path, enough to build an ElementsLocation
|
||||
QString name; ///< display name
|
||||
QString folder; ///< where it lives, for telling similar names apart
|
||||
QIcon icon;
|
||||
};
|
||||
|
||||
class ElementsCollectionWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -56,6 +72,8 @@ class ElementsCollectionWidget : public QWidget
|
||||
void removeProject (QETProject *project);
|
||||
void highlightUnusedElement();
|
||||
void setCurrentLocation(const ElementsLocation &location);
|
||||
QVector<ElementSearchHit> rankedSearch(const QString &text,
|
||||
const QModelIndex &within = QModelIndex());
|
||||
|
||||
protected:
|
||||
void leaveEvent(QEvent *event) override;
|
||||
@@ -86,9 +104,26 @@ class ElementsCollectionWidget : public QWidget
|
||||
public slots:
|
||||
void reload();
|
||||
void loadingFinished();
|
||||
void insertCurrentElement();
|
||||
void editLocation(const ElementsLocation &location);
|
||||
|
||||
signals:
|
||||
/**
|
||||
Emitted when the user asks for an element to be placed on the
|
||||
current folio. Whoever hosts this widget decides which view
|
||||
receives it -- the dock is inside a diagram editor, but the
|
||||
picker popup is not, so the widget must not reach for an
|
||||
ancestor editor itself.
|
||||
*/
|
||||
void insertElementRequested(const ElementsLocation &location);
|
||||
|
||||
private:
|
||||
void locationWasSaved(const ElementsLocation& location);
|
||||
void activateIndex(const QModelIndex &index);
|
||||
void showFlatResults(const QVector<ElementSearchHit> &hits);
|
||||
void clearFlatResults();
|
||||
static int rankMatch(const QString &needle, const QString &name,
|
||||
const QString &haystack);
|
||||
|
||||
|
||||
private:
|
||||
@@ -100,6 +135,9 @@ class ElementsCollectionWidget : public QWidget
|
||||
ElementsTreeView *m_tree_view;
|
||||
ElementsTreeView *m_macros_tree_view = nullptr;
|
||||
QTabWidget *m_tab_widget = nullptr;
|
||||
/// Flat ranked results, shown in place of the tree while searching
|
||||
QListView *m_search_results = nullptr;
|
||||
QStandardItemModel *m_search_model = nullptr;
|
||||
QVBoxLayout *m_main_vlayout;
|
||||
QMenu *m_context_menu;
|
||||
QModelIndex m_index_at_context_menu;
|
||||
|
||||
@@ -82,13 +82,27 @@ void ElementsTreeView::startDrag(Qt::DropActions supportedActions)
|
||||
@param location : location to use for create the content of the QDrag
|
||||
*/
|
||||
void ElementsTreeView::startElementDrag(const ElementsLocation &location)
|
||||
{
|
||||
execElementDrag(this, location);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsTreeView::execElementDrag
|
||||
Build and run the QDrag for @a location, from @a source.
|
||||
Static so that a view which is not an ElementsTreeView -- the flat list
|
||||
of search results -- starts exactly the same drag as the tree.
|
||||
@param source : the widget the drag starts from
|
||||
@param location : location to use for create the content of the QDrag
|
||||
*/
|
||||
void ElementsTreeView::execElementDrag(QWidget *source,
|
||||
const ElementsLocation &location)
|
||||
{
|
||||
if (! location.exist()) return;
|
||||
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 2, 0)
|
||||
QDrag* drag = new QDrag(this);
|
||||
QDrag* drag = new QDrag(source);
|
||||
#else
|
||||
QScopedPointer<QDrag> drag(new QDrag(this));
|
||||
QScopedPointer<QDrag> drag(new QDrag(source));
|
||||
#endif
|
||||
|
||||
QString location_str = location.toString();
|
||||
@@ -209,7 +223,7 @@ void ElementsTreeView::startElementDrag(const ElementsLocation &location)
|
||||
&elmt_creation_state));
|
||||
if (elmt_creation_state) { return; }
|
||||
|
||||
QPixmap elmt_pixmap(QET::Palette::forPalette(temp_elmt->pixmap(), palette()));
|
||||
QPixmap elmt_pixmap(QET::Palette::forPalette(temp_elmt->pixmap(), source->palette()));
|
||||
QPoint elmt_hotspot(temp_elmt->hotspot());
|
||||
|
||||
//Adjust the size of the pixmap if he is too big
|
||||
|
||||
@@ -32,6 +32,8 @@ class ElementsTreeView : public QTreeView
|
||||
{
|
||||
public:
|
||||
ElementsTreeView(QWidget *parent = nullptr);
|
||||
static void execElementDrag(QWidget *source,
|
||||
const ElementsLocation &location);
|
||||
|
||||
protected:
|
||||
void startDrag(Qt::DropActions supportedActions) override;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef ALIGNMENT_H
|
||||
#define ALIGNMENT_H
|
||||
|
||||
#include "textgrid.h"
|
||||
|
||||
#include <QPointF>
|
||||
|
||||
/**
|
||||
The geometry behind the align commands, kept free of any scene so it
|
||||
can be tested on its own.
|
||||
*/
|
||||
namespace Alignment
|
||||
{
|
||||
/**
|
||||
@return the movement that puts p on a grid of x_grid by y_grid,
|
||||
divided by divisor as TextGrid::snap() does, or a null point when
|
||||
p is already on it. Unlike Diagram::snapToGrid(), this never looks
|
||||
at the keyboard: a command run from a shortcut with Ctrl in it must
|
||||
not quietly round to the pixel instead.
|
||||
Less than a millionth of a pixel counts as on the grid: positions
|
||||
that went through arithmetic carry residues of that size, and
|
||||
qFuzzyIsNull() (1e-12) is too strict to absorb them.
|
||||
*/
|
||||
inline QPointF gridOffset(const QPointF &p, int x_grid, int y_grid, qreal divisor = 1)
|
||||
{
|
||||
const QPointF offset = TextGrid::snap(p, x_grid, y_grid, divisor) - p;
|
||||
auto clean = [](qreal v) { return qAbs(v) < 1e-6 ? 0.0 : v; };
|
||||
return QPointF(clean(offset.x()), clean(offset.y()));
|
||||
}
|
||||
}
|
||||
|
||||
#endif // ALIGNMENT_H
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef BORDERCELLLABELS_H
|
||||
#define BORDERCELLLABELS_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
/// The labels the border of a folio writes on its rows and columns, shared
|
||||
/// by BorderTitleBlock::draw() and the cell rulers of DiagramView so the
|
||||
/// two cannot disagree. Header-only so it can be unit-tested directly --
|
||||
/// see tests/qttest/tst_bordercelllabels.cpp.
|
||||
namespace BorderCellLabels {
|
||||
|
||||
/// @return the label of row \a row, counted from 1: A..Z, then AA, AB...
|
||||
/// (the sequence BorderTitleBlock::incrementLetters() walks through).
|
||||
inline QString rowLabel(int row)
|
||||
{
|
||||
QString label;
|
||||
while (row > 0) {
|
||||
--row;
|
||||
label.prepend(QChar('A' + row % 26));
|
||||
row /= 26;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/// @return the label of column \a column, counted from 1. When
|
||||
/// \a starts_at_zero (the "border-columns_0" setting) the first column
|
||||
/// is labelled 0.
|
||||
inline QString columnLabel(int column, bool starts_at_zero)
|
||||
{
|
||||
return QString::number(starts_at_zero ? column - 1 : column);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // BORDERCELLLABELS_H
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
#include "bordertitleblock.h"
|
||||
|
||||
#include "bordercelllabels.h"
|
||||
#include "createdxf.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramposition.h"
|
||||
@@ -29,6 +30,7 @@
|
||||
|
||||
#include <QLocale>
|
||||
#include <QPainter>
|
||||
#include <QRegularExpression>
|
||||
#include <utility>
|
||||
|
||||
#define MIN_COLUMN_COUNT 3
|
||||
@@ -533,6 +535,8 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
|
||||
//Draw the nums of columns
|
||||
if (display_border_ && display_columns_) {
|
||||
const bool columns_start_at_zero =
|
||||
settings.value("border-columns_0", true).toBool();
|
||||
for (int i = 1 ; i <= columns_count_ ; ++ i) {
|
||||
QRectF numbered_rectangle = QRectF(
|
||||
diagram_rect_.topLeft().x()
|
||||
@@ -543,23 +547,15 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
columns_header_height_
|
||||
);
|
||||
painter -> drawRect(numbered_rectangle);
|
||||
if (settings.value("border-columns_0", true).toBool()){
|
||||
painter -> drawText(numbered_rectangle,
|
||||
Qt::AlignVCenter
|
||||
| Qt::AlignCenter,
|
||||
QString("%1").arg(i - 1));
|
||||
}else{
|
||||
painter -> drawText(numbered_rectangle,
|
||||
Qt::AlignVCenter
|
||||
| Qt::AlignCenter,
|
||||
QString("%1").arg(i));
|
||||
}
|
||||
BorderCellLabels::columnLabel(i, columns_start_at_zero));
|
||||
}
|
||||
}
|
||||
|
||||
//Draw the nums of rows
|
||||
if (display_border_ && display_rows_) {
|
||||
QString row_string("A");
|
||||
for (int i = 1 ; i <= rows_count_ ; ++ i) {
|
||||
QRectF lettered_rectangle = QRectF(
|
||||
diagram_rect_.topLeft().x(),
|
||||
@@ -575,8 +571,7 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
painter -> drawText(lettered_rectangle,
|
||||
Qt::AlignVCenter
|
||||
| Qt::AlignCenter,
|
||||
row_string);
|
||||
row_string = incrementLetters(row_string);
|
||||
BorderCellLabels::rowLabel(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,6 +930,46 @@ void BorderTitleBlock::updateDiagramContextForTitleBlock(
|
||||
m_titleblock_template_renderer -> setContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief BorderTitleBlock::cellRect
|
||||
Convert a cell written the way the border labels it (ex : B13, the row
|
||||
letter(s) then the column number) to its rect in scene coordinate.
|
||||
This is the reverse of convertPosition().
|
||||
@param cell : the cell to convert, case and surrounding spaces ignored
|
||||
@return the rect of the cell, or a null QRectF if \a cell is not a
|
||||
cell reference or lies outside of the border.
|
||||
*/
|
||||
QRectF BorderTitleBlock::cellRect(const QString &cell) const
|
||||
{
|
||||
static const QRegularExpression cell_re(
|
||||
QStringLiteral("^\\s*([A-Za-z]+)\\s*(\\d{1,4})\\s*$"));
|
||||
const QRegularExpressionMatch match = cell_re.match(cell);
|
||||
if (!match.hasMatch())
|
||||
return QRectF();
|
||||
|
||||
//Row letters count like A..Z, AA, AB... (see incrementLetters())
|
||||
int row = 0;
|
||||
for (const QChar c : match.captured(1).toUpper()) {
|
||||
row = row * 26 + (c.unicode() - 'A' + 1);
|
||||
if (row > rows_count_)
|
||||
return QRectF();
|
||||
}
|
||||
|
||||
int column = match.captured(2).toInt();
|
||||
QSettings settings;
|
||||
if (settings.value("border-columns_0", true).toBool())
|
||||
++column;
|
||||
|
||||
if (row < 1 || column < 1 || column > columns_count_)
|
||||
return QRectF();
|
||||
|
||||
const QPointF top_left = insideBorderRect().topLeft();
|
||||
return QRectF(top_left.x() + (column - 1) * columns_width_,
|
||||
top_left.y() + (row - 1) * rows_height_,
|
||||
columns_width_,
|
||||
rows_height_);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief BorderTitleBlock::incrementLetters
|
||||
increments string with Letters A to Z
|
||||
|
||||
@@ -159,6 +159,7 @@ class BorderTitleBlock : public QObject
|
||||
void setDiagramHeight(const qreal &);
|
||||
|
||||
DiagramPosition convertPosition(const QPointF &);
|
||||
QRectF cellRect(const QString &cell) const;
|
||||
|
||||
// methods to set title block basic data
|
||||
void setFolio(const QString &folio);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "cellruler.h"
|
||||
|
||||
#include "bordercelllabels.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramview.h"
|
||||
|
||||
#include <QPainter>
|
||||
#include <QSettings>
|
||||
|
||||
/**
|
||||
@brief CellRuler::CellRuler
|
||||
@param orientation : Qt::Horizontal for the column numbers along the
|
||||
top, Qt::Vertical for the row letters along the left
|
||||
@param view : the view this ruler is placed on
|
||||
*/
|
||||
CellRuler::CellRuler(Qt::Orientation orientation, DiagramView *view) :
|
||||
QWidget(view),
|
||||
m_orientation(orientation),
|
||||
m_view(view)
|
||||
{
|
||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
||||
hide();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CellRuler::thickness
|
||||
@return the height of the top ruler, which is also the width of the
|
||||
side ruler, so the corner they share is square. Constant whatever the
|
||||
zoom.
|
||||
*/
|
||||
int CellRuler::thickness() const
|
||||
{
|
||||
const QFontMetrics metrics = fontMetrics();
|
||||
return qMax(metrics.height(), metrics.horizontalAdvance(QStringLiteral("WW"))) + 6;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CellRuler::setLeadingSpace
|
||||
@param space : pixels left empty before the first pixel of the
|
||||
viewport, along this ruler
|
||||
*/
|
||||
void CellRuler::setLeadingSpace(int space)
|
||||
{
|
||||
m_leading_space = space;
|
||||
update();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CellRuler::paintEvent
|
||||
Draw a cell for each column (or row) of the folio border, at the
|
||||
position the view shows it. When the cells get too small for their
|
||||
labels, only one label every 2, 5, 10... cells is written.
|
||||
*/
|
||||
void CellRuler::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
|
||||
QPainter painter(this);
|
||||
painter.fillRect(rect(), background());
|
||||
|
||||
const bool horizontal = m_orientation == Qt::Horizontal;
|
||||
const int length = horizontal ? width() : height();
|
||||
const int depth = horizontal ? height() : width();
|
||||
|
||||
//Separate the ruler from the drawing
|
||||
painter.setPen(palette().color(QPalette::Dark));
|
||||
if (horizontal) {
|
||||
painter.drawLine(0, depth - 1, length, depth - 1);
|
||||
} else {
|
||||
painter.drawLine(depth - 1, 0, depth - 1, length);
|
||||
}
|
||||
|
||||
Diagram *diagram = m_view->diagram();
|
||||
if (!diagram) {
|
||||
return;
|
||||
}
|
||||
const BorderTitleBlock &border = diagram->border_and_titleblock;
|
||||
const QTransform transform = m_view->viewportTransform();
|
||||
|
||||
const int count = horizontal ? border.columnsCount() : border.rowsCount();
|
||||
const qreal cell_size = horizontal ? border.columnsWidth() : border.rowsHeight();
|
||||
//Where BorderTitleBlock::draw() puts the first cell: after the
|
||||
//other header's room even when that header is hidden, which
|
||||
//insideBorderRect() does not account for
|
||||
const qreal first = Diagram::margin
|
||||
+ (horizontal ? border.rowsHeaderWidth() : border.columnsHeaderHeight());
|
||||
const qreal scale = horizontal ? transform.m11() : transform.m22();
|
||||
const qreal offset = (horizontal ? transform.dx() : transform.dy()) + m_leading_space;
|
||||
const qreal cell_pixels = cell_size * scale;
|
||||
if (count < 1 || cell_pixels <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool columns_start_at_zero =
|
||||
QSettings().value("border-columns_0", true).toBool();
|
||||
auto label = [&](int index) {
|
||||
return horizontal ? BorderCellLabels::columnLabel(index, columns_start_at_zero)
|
||||
: BorderCellLabels::rowLabel(index);
|
||||
};
|
||||
|
||||
//Room one label needs along the ruler, and the smallest step
|
||||
//between written labels that gives it that room
|
||||
const QFontMetrics metrics = fontMetrics();
|
||||
const int label_room = horizontal
|
||||
? metrics.horizontalAdvance(label(count)) + 6
|
||||
: metrics.height() + 2;
|
||||
int step = 1;
|
||||
for (int candidate : {1, 2, 5, 10, 20, 50, 100, 200, 500}) {
|
||||
step = candidate;
|
||||
if (candidate * cell_pixels >= label_room) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1 ; i <= count ; ++i) {
|
||||
const qreal start = offset + (first + (i - 1) * cell_size) * scale;
|
||||
const qreal end = start + cell_pixels;
|
||||
if (end < m_leading_space || start > length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//Cell edges, drawn only when the cells are wide enough
|
||||
//for them to read as cells rather than as a hatching
|
||||
painter.setPen(palette().color(QPalette::Dark));
|
||||
if (cell_pixels >= 4) {
|
||||
if (horizontal) {
|
||||
painter.drawLine(QPointF(start, 0), QPointF(start, depth - 1));
|
||||
if (i == count) painter.drawLine(QPointF(end, 0), QPointF(end, depth - 1));
|
||||
} else {
|
||||
painter.drawLine(QPointF(0, start), QPointF(depth - 1, start));
|
||||
if (i == count) painter.drawLine(QPointF(0, end), QPointF(depth - 1, end));
|
||||
}
|
||||
}
|
||||
|
||||
//Written labels are the ones a multiple of step: 0, 5, 10...
|
||||
//for the columns, A, F, K... for the rows
|
||||
const int position = (horizontal && !columns_start_at_zero) ? i : i - 1;
|
||||
if (position % step != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
painter.setPen(palette().color(QPalette::ButtonText));
|
||||
const qreal centre = (start + end) / 2;
|
||||
const QRectF text_rect = horizontal
|
||||
? QRectF(centre - label_room / 2.0, 0, label_room, depth - 1)
|
||||
: QRectF(0, centre - label_room / 2.0, depth - 1, label_room);
|
||||
painter.drawText(text_rect, Qt::AlignCenter | Qt::TextDontClip, label(i));
|
||||
}
|
||||
|
||||
//Keep the corner empty: the other ruler's labels do not belong there
|
||||
if (m_leading_space > 0) {
|
||||
painter.fillRect(horizontal ? QRect(0, 0, m_leading_space, depth - 1)
|
||||
: QRect(0, 0, depth - 1, m_leading_space),
|
||||
background());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CellRuler::background
|
||||
@return the button colour laid over the window colour, always opaque.
|
||||
The Windows 11 style gives buttons a translucent colour; filled with it
|
||||
as is, a ruler (painted with Qt::WA_OpaquePaintEvent, so never cleared
|
||||
first) would let every previous frame show through, and zooming would
|
||||
leave a shadow of the old labels behind the new ones.
|
||||
*/
|
||||
QColor CellRuler::background() const
|
||||
{
|
||||
const QColor window = palette().color(QPalette::Window);
|
||||
const QColor button = palette().color(QPalette::Button);
|
||||
const qreal alpha = button.alphaF();
|
||||
return QColor::fromRgbF(
|
||||
button.redF() * alpha + window.redF() * (1 - alpha),
|
||||
button.greenF() * alpha + window.greenF() * (1 - alpha),
|
||||
button.blueF() * alpha + window.blueF() * (1 - alpha));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef CELLRULER_H
|
||||
#define CELLRULER_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class DiagramView;
|
||||
|
||||
/**
|
||||
@brief The CellRuler class
|
||||
A bar along the top or the left edge of a DiagramView that repeats the
|
||||
column numbers or the row letters of the folio border, aligned with the
|
||||
cells at any zoom, so they stay in sight however far the view is
|
||||
scrolled. It sits in the view's margins, outside of the scene: printing
|
||||
and exporting are unaffected.
|
||||
*/
|
||||
class CellRuler : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
CellRuler(Qt::Orientation orientation, DiagramView *view);
|
||||
|
||||
int thickness() const;
|
||||
void setLeadingSpace(int space);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
|
||||
private:
|
||||
QColor background() const;
|
||||
|
||||
Qt::Orientation m_orientation;
|
||||
DiagramView *m_view;
|
||||
/// Pixels before the viewport starts, left empty: the corner the
|
||||
/// side ruler fills when both rulers are shown.
|
||||
int m_leading_space = 0;
|
||||
};
|
||||
|
||||
#endif // CELLRULER_H
|
||||
@@ -227,6 +227,7 @@ int exportPdf(QETProject &project, const QString &output,
|
||||
// Rewrite the URI link annotations into native internal GoTo actions, so
|
||||
// the cross-references jump inside the document in any PDF viewer.
|
||||
PdfLinks::convertUriToGoTo(output);
|
||||
PdfLinks::removeUnusedPdfxNamespace(output);
|
||||
|
||||
out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n";
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "commandsearchpopup.h"
|
||||
|
||||
#include "shortcutmanager.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QGuiApplication>
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QScreen>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
/**
|
||||
@brief CommandSearchPopup::CommandSearchPopup
|
||||
@param owner : the window whose commands are searched; also the parent
|
||||
*/
|
||||
CommandSearchPopup::CommandSearchPopup(QWidget *owner) :
|
||||
QFrame(owner, Qt::Popup)
|
||||
{
|
||||
setFrameShape(QFrame::StyledPanel);
|
||||
setFrameShadow(QFrame::Raised);
|
||||
setMinimumWidth(380);
|
||||
|
||||
m_search = new QLineEdit(this);
|
||||
m_search->setPlaceholderText(tr("Rechercher une commande…"));
|
||||
m_search->setClearButtonEnabled(true);
|
||||
m_list = new QListWidget(this);
|
||||
m_list->setIconSize(QSize(20, 20));
|
||||
m_list->setMinimumHeight(280);
|
||||
m_list->setFocusPolicy(Qt::NoFocus);
|
||||
|
||||
auto *hint = new QLabel(tr("Entrée pour lancer · Échap pour fermer"), this);
|
||||
hint->setEnabled(false);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(6, 6, 6, 6);
|
||||
layout->setSpacing(4);
|
||||
layout->addWidget(m_search);
|
||||
layout->addWidget(m_list);
|
||||
layout->addWidget(hint);
|
||||
|
||||
connect(m_search, &QLineEdit::textChanged, this, &CommandSearchPopup::filter);
|
||||
connect(m_list, &QListWidget::itemClicked, this, [this]() { runCurrent(); });
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CommandSearchPopup::fold
|
||||
@return @a text lower-cased, without accents and without the "&" of
|
||||
mnemonics, for matching
|
||||
*/
|
||||
QString CommandSearchPopup::fold(const QString &text)
|
||||
{
|
||||
QString out;
|
||||
const QString decomposed = text.normalized(QString::NormalizationForm_D);
|
||||
out.reserve(decomposed.size());
|
||||
for (const QChar c : decomposed) {
|
||||
if (c.category() != QChar::Mark_NonSpacing && c != QLatin1Char('&')) {
|
||||
out.append(c.toLower());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CommandSearchPopup::popUpAt
|
||||
Rebuild the command list -- actions come and go with windows, and their
|
||||
enabled state changes -- then show at @a global_pos, kept on screen.
|
||||
*/
|
||||
void CommandSearchPopup::popUpAt(const QPoint &global_pos)
|
||||
{
|
||||
collect();
|
||||
m_search->clear();
|
||||
filter();
|
||||
|
||||
adjustSize();
|
||||
QPoint pos = global_pos;
|
||||
if (QScreen *screen = QGuiApplication::screenAt(global_pos)) {
|
||||
const QRect avail = screen->availableGeometry();
|
||||
pos.setX(qBound(avail.left(), pos.x(), avail.right() - width()));
|
||||
pos.setY(qBound(avail.top(), pos.y(), avail.bottom() - height()));
|
||||
}
|
||||
move(pos);
|
||||
show();
|
||||
m_search->setFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CommandSearchPopup::collect
|
||||
Every command registered by the owning window, except this search.
|
||||
*/
|
||||
void CommandSearchPopup::collect()
|
||||
{
|
||||
m_commands.clear();
|
||||
for (const ShortcutManager::ShortcutInfo &info :
|
||||
ShortcutManager::instance().allShortcuts())
|
||||
{
|
||||
if (info.id == QLatin1String("diagrameditor.command_search")) {
|
||||
continue;
|
||||
}
|
||||
QAction *action = ShortcutManager::instance().action(info.id, parentWidget());
|
||||
if (!action || !action->isVisible()) {
|
||||
continue;
|
||||
}
|
||||
const QString text = action->text().remove(QLatin1Char('&'));
|
||||
if (text.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
m_commands.append({action, text, fold(text)});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CommandSearchPopup::filter
|
||||
Show the commands matching the search text, best first: a name starting
|
||||
with it, then a word starting with it, then containing it. An empty
|
||||
search lists everything, alphabetically.
|
||||
*/
|
||||
void CommandSearchPopup::filter()
|
||||
{
|
||||
const QString needle = fold(m_search->text().trimmed());
|
||||
|
||||
QList<QPair<int, const Command *>> hits;
|
||||
for (const Command &command : std::as_const(m_commands))
|
||||
{
|
||||
int score = 0;
|
||||
if (needle.isEmpty()) {
|
||||
score = 1;
|
||||
} else if (command.folded.startsWith(needle)) {
|
||||
score = 3;
|
||||
} else if (command.folded.contains(QLatin1Char(' ') + needle)) {
|
||||
score = 2;
|
||||
} else if (command.folded.contains(needle)) {
|
||||
score = 1;
|
||||
}
|
||||
if (score) {
|
||||
hits.append({score, &command});
|
||||
}
|
||||
}
|
||||
std::stable_sort(hits.begin(), hits.end(), [](const auto &a, const auto &b) {
|
||||
if (a.first != b.first) {
|
||||
return a.first > b.first;
|
||||
}
|
||||
return a.second->folded < b.second->folded;
|
||||
});
|
||||
|
||||
m_list->clear();
|
||||
for (const auto &hit : std::as_const(hits))
|
||||
{
|
||||
QAction *action = hit.second->action;
|
||||
const QKeySequence key = action->shortcut();
|
||||
auto *item = new QListWidgetItem(
|
||||
action->icon(),
|
||||
key.isEmpty() ? hit.second->text
|
||||
: QStringLiteral("%1 (%2)").arg(hit.second->text,
|
||||
key.toString(QKeySequence::NativeText)));
|
||||
item->setData(Qt::UserRole, QVariant::fromValue(static_cast<void *>(action)));
|
||||
if (!action->isEnabled()) {
|
||||
item->setFlags(item->flags() & ~Qt::ItemIsEnabled);
|
||||
}
|
||||
m_list->addItem(item);
|
||||
}
|
||||
|
||||
//Preselect the first command that can run, so Enter works at once
|
||||
for (int i = 0 ; i < m_list->count() ; ++i) {
|
||||
if (m_list->item(i)->flags() & Qt::ItemIsEnabled) {
|
||||
m_list->setCurrentRow(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CommandSearchPopup::runCurrent
|
||||
Close, then trigger the highlighted command: it may open a dialog or
|
||||
start a tool on the folio, which need the focus the popup holds.
|
||||
*/
|
||||
void CommandSearchPopup::runCurrent()
|
||||
{
|
||||
QListWidgetItem *item = m_list->currentItem();
|
||||
if (!item || !(item->flags() & Qt::ItemIsEnabled)) {
|
||||
return;
|
||||
}
|
||||
auto *action = static_cast<QAction *>(item->data(Qt::UserRole).value<void *>());
|
||||
hide();
|
||||
if (action) {
|
||||
action->trigger();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CommandSearchPopup::keyPressEvent
|
||||
Up and Down move through the list while typing goes on in the search
|
||||
field; Enter runs, Esc closes.
|
||||
*/
|
||||
void CommandSearchPopup::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
switch (event->key())
|
||||
{
|
||||
case Qt::Key_Escape:
|
||||
hide();
|
||||
return;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
runCurrent();
|
||||
return;
|
||||
case Qt::Key_Down:
|
||||
case Qt::Key_Up:
|
||||
case Qt::Key_PageDown:
|
||||
case Qt::Key_PageUp:
|
||||
QCoreApplication::sendEvent(m_list, event);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
QFrame::keyPressEvent(event);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef COMMANDSEARCHPOPUP_H
|
||||
#define COMMANDSEARCHPOPUP_H
|
||||
|
||||
#include <QFrame>
|
||||
#include <QList>
|
||||
|
||||
class QAction;
|
||||
class QLineEdit;
|
||||
class QListWidget;
|
||||
|
||||
/**
|
||||
@brief Type part of a command's name, press Enter to run it.
|
||||
|
||||
Lists every command the owning window registered with ShortcutManager,
|
||||
best match first, with its key if it has one. Matching ignores case and
|
||||
accents, so "editer" finds "Éditer". Disabled commands are listed,
|
||||
greyed, but cannot be run.
|
||||
*/
|
||||
class CommandSearchPopup : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit CommandSearchPopup(QWidget *owner);
|
||||
|
||||
void popUpAt(const QPoint &global_pos);
|
||||
static QString fold(const QString &text);
|
||||
|
||||
protected:
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
|
||||
private:
|
||||
void collect();
|
||||
void filter();
|
||||
void runCurrent();
|
||||
|
||||
struct Command {
|
||||
QAction *action;
|
||||
QString text; ///< as shown
|
||||
QString folded; ///< for matching
|
||||
};
|
||||
|
||||
QLineEdit *m_search = nullptr;
|
||||
QListWidget *m_list = nullptr;
|
||||
QList<Command> m_commands;
|
||||
};
|
||||
|
||||
#endif // COMMANDSEARCHPOPUP_H
|
||||
+16
-2
@@ -27,6 +27,20 @@ const double Createdxf::sheetWidth = 4000;
|
||||
const double Createdxf::sheetHeight = 2700;
|
||||
|
||||
double Createdxf::xScale = 1;
|
||||
|
||||
namespace {
|
||||
/// @p text on one line. A DXF value ends at the line break, so a break
|
||||
/// inside a text would shift every group code after it by one line and
|
||||
/// a reader would take values for codes (FINDINGS F052). Callers that
|
||||
/// mean several lines split them first; this is the safety net.
|
||||
QString singleLine(QString text)
|
||||
{
|
||||
text.replace(QLatin1String("\r\n"), QLatin1String(" "));
|
||||
text.replace(QLatin1Char('\r'), QLatin1Char(' '));
|
||||
text.replace(QLatin1Char('\n'), QLatin1Char(' '));
|
||||
return text;
|
||||
}
|
||||
}
|
||||
double Createdxf::yScale = 1;
|
||||
|
||||
Createdxf::Createdxf()
|
||||
@@ -727,7 +741,7 @@ void Createdxf::drawText(
|
||||
To_Dxf << 41 << "\r\n";
|
||||
To_Dxf << xScaleW << "\r\n"; // X Scale
|
||||
To_Dxf << 1 << "\r\n";
|
||||
To_Dxf << text << "\r\n"; // Text Value
|
||||
To_Dxf << singleLine(text) << "\r\n"; // Text Value
|
||||
To_Dxf << 50 << "\r\n";
|
||||
To_Dxf << rotation << "\r\n"; // Text Rotation
|
||||
file.close();
|
||||
@@ -780,7 +794,7 @@ void Createdxf::drawTextAligned(
|
||||
To_Dxf << 41 << "\r\n";
|
||||
To_Dxf << xScaleW << "\r\n"; // X Scale
|
||||
To_Dxf << 1 << "\r\n";
|
||||
To_Dxf << text << "\r\n"; // Text Value
|
||||
To_Dxf << singleLine(text) << "\r\n"; // Text Value
|
||||
To_Dxf << 50 << "\r\n";
|
||||
To_Dxf << rotation << "\r\n"; // Text Rotation
|
||||
#if 0
|
||||
|
||||
@@ -24,17 +24,21 @@
|
||||
#include "../elementprovider.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
#include "../qetgraphicsitem/diagramimageitem.h"
|
||||
#include "../qetgraphicsitem/element.h"
|
||||
#include "../qetgraphicsitem/independenttextitem.h"
|
||||
#include "../qetgraphicsitem/qetshapeitem.h"
|
||||
#include "../qetgraphicsitem/terminal.h"
|
||||
#include "../qetinformation.h"
|
||||
#include "../qetproject.h"
|
||||
|
||||
#include <QLocale>
|
||||
#include <QMetaEnum>
|
||||
#include <QTextDocument>
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QSqlDriver>
|
||||
#include <QSqlError>
|
||||
#include <sqlite3.h>
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +108,7 @@ void projectDataBase::updateDB()
|
||||
//refresh, and what they read back is unchanged either way.
|
||||
if (!m_content_changed)
|
||||
{
|
||||
flushDrawingItems();
|
||||
emit dataBaseUpdated();
|
||||
return;
|
||||
}
|
||||
@@ -113,6 +118,7 @@ void projectDataBase::updateDB()
|
||||
populateElementTable();
|
||||
populateElementInfoTable();
|
||||
populateConductorTable();
|
||||
populateDrawingItemTables();
|
||||
m_content_changed = false;
|
||||
|
||||
emit dataBaseUpdated();
|
||||
@@ -205,14 +211,18 @@ bool projectDataBase::isReadOnlySelect(const QString &query, QString *error)
|
||||
@param query the SQL text to run -- must be a single read-only
|
||||
SELECT/WITH statement, see isReadOnlySelect()
|
||||
@param error set to a human-readable reason when the query was rejected
|
||||
before ever reaching the database
|
||||
@return a QSqlQuery with query as query and the internal database of
|
||||
this class as database to use, or an unexecuted, harmless QSqlQuery if
|
||||
the query was rejected
|
||||
or failed
|
||||
@return the executed query, on the internal database of this class, or
|
||||
an empty, harmless QSqlQuery if the query was rejected or failed
|
||||
*/
|
||||
QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
|
||||
QString reason;
|
||||
|
||||
//Drawing-item rows are rewritten lazily, see drawingItemChanged().
|
||||
//Every read from outside comes through here, so this is the one
|
||||
//place the queue has to be emptied for a reader to see current rows.
|
||||
flushDrawingItems();
|
||||
|
||||
// First gate: which kind of statement is acceptable here at all. A
|
||||
// textual check is the right tool for that and the wrong tool for
|
||||
// anything else -- see isReadOnlySelect()'s own comment. It is what
|
||||
@@ -226,24 +236,24 @@ QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
|
||||
return QSqlQuery(m_data_base);
|
||||
}
|
||||
|
||||
// Second gate, and the one that actually enforces read-only: SQLite is
|
||||
// asked about the statement it compiled, instead of the text being read
|
||||
// for clues. The first gate cannot see through a CTE prefix --
|
||||
// "WITH x AS (SELECT 1) DELETE FROM element" starts with WITH, contains
|
||||
// no semicolon, and deletes every row. That matters beyond the
|
||||
// custom-query box, because this path is reachable from a file: a
|
||||
// <graphics_table>'s saved <query> is read straight out of the .qet by
|
||||
// ProjectDBModel::fromXml() and executed by fillValue(), so opening or
|
||||
// exporting a project someone else produced would have been enough.
|
||||
if (!QETSql::isSingleReadOnlyStatement(sqliteHandle(&m_data_base), query, &reason)) {
|
||||
// Second gate, and the one that actually enforces read-only: SQLite
|
||||
// runs the statement with query_only set and refuses a write itself,
|
||||
// instead of the text being read for clues. The first gate cannot see
|
||||
// through a CTE prefix -- "WITH x AS (SELECT 1) DELETE FROM element"
|
||||
// starts with WITH, contains no semicolon, and deletes every row. That
|
||||
// matters beyond the custom-query box, because this path is reachable
|
||||
// from a file: a <graphics_table>'s saved <query> is read straight out
|
||||
// of the .qet by ProjectDBModel::fromXml() and executed by fillValue(),
|
||||
// so opening or exporting a project someone else produced would have
|
||||
// been enough.
|
||||
QSqlQuery result = QETSql::execReadOnly(m_data_base, query, &reason);
|
||||
if (!reason.isEmpty()) {
|
||||
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
|
||||
if (error) {
|
||||
*error = reason;
|
||||
}
|
||||
return QSqlQuery(m_data_base);
|
||||
}
|
||||
|
||||
return QSqlQuery(query, m_data_base);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,6 +401,13 @@ void projectDataBase::addDiagram(Diagram *diagram)
|
||||
qDebug() << "projectDataBase::addDiagram insert info error : " << m_insert_diagram_info_query.lastError();
|
||||
}
|
||||
|
||||
//A folio put back by undoing its removal comes with its items already
|
||||
//on it, and their rows went with it: queue them again.
|
||||
const QList<QGraphicsItem *> items = diagram->items();
|
||||
for (QGraphicsItem *item : items) {
|
||||
addDrawingItem(item);
|
||||
}
|
||||
|
||||
//The information "folio" of other existing diagram can have the variable %total,
|
||||
//so when a new diagram is added this variable change.
|
||||
//We need to update this information in the database.
|
||||
@@ -450,6 +467,31 @@ void projectDataBase::removeDiagram(Diagram *diagram)
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QString &table : {QStringLiteral("shape"),
|
||||
QStringLiteral("independent_text"),
|
||||
QStringLiteral("image")})
|
||||
{
|
||||
QSqlQuery cascade(m_data_base);
|
||||
cascade.prepare(QStringLiteral("DELETE FROM %1 WHERE diagram_uuid = :uuid").arg(table));
|
||||
cascade.bindValue(QStringLiteral(":uuid"), uuid_str);
|
||||
if (!cascade.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram" << table << "cascade error : "
|
||||
<< cascade.lastError();
|
||||
m_data_base.rollback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
//The folio's items keep existing (the removal can be undone), but
|
||||
//their rows are gone: forget them, so that nothing is deleted or
|
||||
//skipped later on the strength of a row that no longer exists.
|
||||
const QList<QObject *> tracked = m_drawing_item_row.keys();
|
||||
for (QObject *object : tracked) {
|
||||
auto *item = dynamic_cast<QGraphicsItem *>(object);
|
||||
if (item && item->scene() == diagram) {
|
||||
forgetDrawingItem(object);
|
||||
}
|
||||
}
|
||||
|
||||
m_remove_diagram_query.bindValue(":uuid", uuid_str);
|
||||
if (!m_remove_diagram_query.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram delete error : " << m_remove_diagram_query.lastError();
|
||||
@@ -608,6 +650,305 @@ void projectDataBase::bindConductorValues(QSqlQuery &query, Conductor *conductor
|
||||
query.bindValue(QStringLiteral(":text"), conductor->properties().text);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/// Table holding @p object's row, or an empty string if it has none.
|
||||
QString drawingItemTable(QObject *object)
|
||||
{
|
||||
if (qobject_cast<QetShapeItem *>(object)) return QStringLiteral("shape");
|
||||
if (qobject_cast<IndependentTextItem *>(object)) return QStringLiteral("independent_text");
|
||||
if (qobject_cast<DiagramImageItem *>(object)) return QStringLiteral("image");
|
||||
return QString();
|
||||
}
|
||||
|
||||
QUuid drawingItemUuid(QObject *object)
|
||||
{
|
||||
if (auto s = qobject_cast<QetShapeItem *>(object)) return s->uuid();
|
||||
if (auto t = qobject_cast<IndependentTextItem *>(object)) return t->uuid();
|
||||
if (auto i = qobject_cast<DiagramImageItem *>(object)) return i->uuid();
|
||||
return QUuid();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
@brief projectDataBase::addDrawingItem
|
||||
Start keeping a row for a shape, an independent text or an image that
|
||||
was just added to a folio. Anything else is ignored, so Diagram::addItem()
|
||||
can pass every item it gets.
|
||||
|
||||
The row is not written here but queued, like every later change to it:
|
||||
see drawingItemChanged() for why.
|
||||
@param item
|
||||
*/
|
||||
void projectDataBase::addDrawingItem(QGraphicsItem *item)
|
||||
{
|
||||
QGraphicsObject *object = item ? item->toGraphicsObject() : nullptr;
|
||||
if (!object || drawingItemTable(object).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto unique = Qt::UniqueConnection;
|
||||
connect(object, &QGraphicsObject::xChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(object, &QGraphicsObject::yChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(object, &QGraphicsObject::rotationChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(object, &QObject::destroyed, this, &projectDataBase::drawingItemDestroyed, unique);
|
||||
|
||||
if (auto shape = qobject_cast<QetShapeItem *>(object)) {
|
||||
connect(shape, &QetShapeItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::geometryChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::transformChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::penChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::brushChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
}
|
||||
else if (auto text = qobject_cast<IndependentTextItem *>(object)) {
|
||||
connect(text, &IndependentTextItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
//Sent by the document, not the item: drawingItemChanged() walks
|
||||
//back up to the item. It is the one signal that catches every way
|
||||
//the text changes -- typing, undo, a script's setTextContent().
|
||||
connect(text->document(), &QTextDocument::contentsChanged,
|
||||
this, &projectDataBase::drawingItemChanged, unique);
|
||||
}
|
||||
else if (auto image = qobject_cast<DiagramImageItem *>(object)) {
|
||||
connect(image, &DiagramImageItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(image, &DiagramImageItem::transformChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(image, &DiagramImageItem::pixmapChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
}
|
||||
|
||||
m_dirty_drawing_items.insert(object);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::removeDrawingItem
|
||||
Drop the row of a shape, independent text or image taken off its folio.
|
||||
The item itself usually lives on in the undo stack, so it is also
|
||||
disconnected: a change to it there must not bring its row back.
|
||||
@param item
|
||||
*/
|
||||
void projectDataBase::removeDrawingItem(QGraphicsItem *item)
|
||||
{
|
||||
QGraphicsObject *object = item ? item->toGraphicsObject() : nullptr;
|
||||
if (!object || drawingItemTable(object).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnect(object, nullptr, this, nullptr);
|
||||
if (auto text = qobject_cast<IndependentTextItem *>(object)) {
|
||||
disconnect(text->document(), nullptr, this, nullptr);
|
||||
}
|
||||
|
||||
const QUuid row = m_drawing_item_row.value(object);
|
||||
if (!row.isNull() && m_drawing_row_owner.value(row) == object)
|
||||
{
|
||||
QSqlQuery remove(m_data_base);
|
||||
remove.prepare(QStringLiteral("DELETE FROM %1 WHERE uuid = :uuid")
|
||||
.arg(drawingItemTable(object)));
|
||||
remove.bindValue(QStringLiteral(":uuid"), row.toString());
|
||||
if (!remove.exec()) {
|
||||
qDebug() << "projectDataBase::removeDrawingItem delete error : " << remove.lastError();
|
||||
}
|
||||
}
|
||||
forgetDrawingItem(object);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::drawingItemChanged
|
||||
Queue the sender's row to be rewritten.
|
||||
|
||||
Queued rather than written: a move sends xChanged/yChanged for every
|
||||
mouse step of every selected item, and nothing reads the rows between
|
||||
two steps. newQuery() and updateDB() flush the queue before anything
|
||||
does, so a reader never sees a stale row -- a queued write only costs a
|
||||
set insertion.
|
||||
*/
|
||||
void projectDataBase::drawingItemChanged()
|
||||
{
|
||||
QObject *object = sender();
|
||||
//QTextDocument::contentsChanged: the item is an ancestor of the
|
||||
//document (item -> text control -> document).
|
||||
while (object && drawingItemTable(object).isEmpty()) {
|
||||
object = object->parent();
|
||||
}
|
||||
if (object) {
|
||||
m_dirty_drawing_items.insert(object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::drawingItemDestroyed
|
||||
An item deleted while still on its folio (e.g. by the scene's own
|
||||
destructor) never went through removeDrawingItem(). Its type can no
|
||||
longer be asked -- this runs from QObject's destructor -- so its row,
|
||||
if it wrote one, is looked for in all three tables.
|
||||
@param object
|
||||
*/
|
||||
void projectDataBase::drawingItemDestroyed(QObject *object)
|
||||
{
|
||||
const QUuid row = m_drawing_item_row.value(object);
|
||||
if (!row.isNull() && m_drawing_row_owner.value(row) == object)
|
||||
{
|
||||
for (const QString &table : {QStringLiteral("shape"),
|
||||
QStringLiteral("independent_text"),
|
||||
QStringLiteral("image")})
|
||||
{
|
||||
QSqlQuery remove(m_data_base);
|
||||
remove.prepare(QStringLiteral("DELETE FROM %1 WHERE uuid = :uuid").arg(table));
|
||||
remove.bindValue(QStringLiteral(":uuid"), row.toString());
|
||||
remove.exec();
|
||||
}
|
||||
}
|
||||
forgetDrawingItem(object);
|
||||
}
|
||||
|
||||
void projectDataBase::forgetDrawingItem(QObject *object)
|
||||
{
|
||||
const QUuid row = m_drawing_item_row.take(object);
|
||||
if (!row.isNull() && m_drawing_row_owner.value(row) == object) {
|
||||
m_drawing_row_owner.remove(row);
|
||||
}
|
||||
m_dirty_drawing_items.remove(object);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::writeDrawingItem
|
||||
Write @p object's row under its current uuid.
|
||||
@return false if the row must wait: another live item still owns that
|
||||
uuid. That is a pasted copy in the moment between being added to the
|
||||
folio and PasteDiagramCommand giving it its own uuid; writing then would
|
||||
overwrite its source's row with the copy's position. The copy's
|
||||
uuidChanged() queues it again once it has one.
|
||||
*/
|
||||
bool projectDataBase::writeDrawingItem(QObject *object)
|
||||
{
|
||||
auto *item = dynamic_cast<QGraphicsItem *>(object);
|
||||
auto *diagram = item ? qobject_cast<Diagram *>(item->scene()) : nullptr;
|
||||
if (!diagram || !m_project || !m_project->diagrams().contains(diagram)) {
|
||||
//Not on a folio of this project (any more): nothing to write,
|
||||
//and nothing to wait for.
|
||||
return true;
|
||||
}
|
||||
|
||||
const QUuid uuid = drawingItemUuid(object);
|
||||
QObject *owner = m_drawing_row_owner.value(uuid);
|
||||
if (owner && owner != object) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery *query = nullptr;
|
||||
const QRectF rect = item->sceneBoundingRect();
|
||||
if (auto shape = qobject_cast<QetShapeItem *>(object))
|
||||
{
|
||||
query = &m_insert_shape_query;
|
||||
const QMetaEnum type = QetShapeItem::staticMetaObject.enumerator(
|
||||
QetShapeItem::staticMetaObject.indexOfEnumerator("ShapeType"));
|
||||
query->bindValue(QStringLiteral(":type"), QString::fromLatin1(type.valueToKey(shape->shapeType())));
|
||||
query->bindValue(QStringLiteral(":color"), shape->pen().color().name());
|
||||
query->bindValue(QStringLiteral(":fill"), shape->brush().style() == Qt::NoBrush
|
||||
? QStringLiteral("none")
|
||||
: shape->brush().color().name());
|
||||
}
|
||||
else if (auto text = qobject_cast<IndependentTextItem *>(object))
|
||||
{
|
||||
query = &m_insert_independent_text_query;
|
||||
query->bindValue(QStringLiteral(":text"), text->toPlainText());
|
||||
query->bindValue(QStringLiteral(":rotation"), text->rotation());
|
||||
}
|
||||
else if (auto image = qobject_cast<DiagramImageItem *>(object))
|
||||
{
|
||||
query = &m_insert_image_query;
|
||||
query->bindValue(QStringLiteral(":pixel_width"), image->pixmap().width());
|
||||
query->bindValue(QStringLiteral(":pixel_height"), image->pixmap().height());
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//Renewed since its last write (a paste, a folio duplication): its
|
||||
//old row was its own, and describes nothing now.
|
||||
const QUuid previous = m_drawing_item_row.value(object);
|
||||
if (!previous.isNull() && previous != uuid
|
||||
&& m_drawing_row_owner.value(previous) == object)
|
||||
{
|
||||
QSqlQuery remove(m_data_base);
|
||||
remove.prepare(QStringLiteral("DELETE FROM %1 WHERE uuid = :uuid")
|
||||
.arg(drawingItemTable(object)));
|
||||
remove.bindValue(QStringLiteral(":uuid"), previous.toString());
|
||||
remove.exec();
|
||||
m_drawing_row_owner.remove(previous);
|
||||
}
|
||||
|
||||
query->bindValue(QStringLiteral(":uuid"), uuid.toString());
|
||||
query->bindValue(QStringLiteral(":diagram_uuid"), diagram->uuid().toString());
|
||||
query->bindValue(QStringLiteral(":pos"), diagram->convertPosition(rect.topLeft()).toString());
|
||||
query->bindValue(QStringLiteral(":x"), rect.x());
|
||||
query->bindValue(QStringLiteral(":y"), rect.y());
|
||||
query->bindValue(QStringLiteral(":width"), rect.width());
|
||||
query->bindValue(QStringLiteral(":height"), rect.height());
|
||||
if (!query->exec()) {
|
||||
qDebug() << "projectDataBase::writeDrawingItem error : " << query->lastError();
|
||||
return true;
|
||||
}
|
||||
|
||||
m_drawing_item_row.insert(object, uuid);
|
||||
m_drawing_row_owner.insert(uuid, object);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::flushDrawingItems
|
||||
Write every queued drawing-item row. A row that has to wait for its uuid
|
||||
(see writeDrawingItem()) stays queued.
|
||||
*/
|
||||
void projectDataBase::flushDrawingItems()
|
||||
{
|
||||
if (m_dirty_drawing_items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QSet<QObject *> dirty = m_dirty_drawing_items;
|
||||
//One transaction for the batch, unless a caller already holds one.
|
||||
const bool own_transaction = m_data_base.transaction();
|
||||
for (QObject *object : dirty) {
|
||||
if (writeDrawingItem(object)) {
|
||||
m_dirty_drawing_items.remove(object);
|
||||
}
|
||||
}
|
||||
if (own_transaction) {
|
||||
m_data_base.commit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::populateDrawingItemTables
|
||||
Rebuild the shape, independent_text and image tables from every folio.
|
||||
*/
|
||||
void projectDataBase::populateDrawingItemTables()
|
||||
{
|
||||
QSqlQuery query_(m_data_base);
|
||||
query_.exec(QStringLiteral("DELETE FROM shape"));
|
||||
query_.exec(QStringLiteral("DELETE FROM independent_text"));
|
||||
query_.exec(QStringLiteral("DELETE FROM image"));
|
||||
m_drawing_item_row.clear();
|
||||
m_drawing_row_owner.clear();
|
||||
m_dirty_drawing_items.clear();
|
||||
|
||||
//Queued directly, not through addDrawingItem(): every one of them
|
||||
//came onto its folio through Diagram::addItem(), which connected it
|
||||
//already, and a full rebuild runs on every load.
|
||||
for (auto diagram : m_project->diagrams())
|
||||
{
|
||||
const QList<QGraphicsItem *> items = diagram->items();
|
||||
for (QGraphicsItem *item : items)
|
||||
{
|
||||
QGraphicsObject *object = item->toGraphicsObject();
|
||||
if (object && !drawingItemTable(object).isEmpty()) {
|
||||
m_dirty_drawing_items.insert(object);
|
||||
}
|
||||
}
|
||||
}
|
||||
flushDrawingItems();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::createDataBase
|
||||
Create the data base
|
||||
@@ -737,9 +1078,37 @@ bool projectDataBase::createDataBase()
|
||||
}
|
||||
}
|
||||
|
||||
//The folio's drawing furniture: shapes, independent texts, images.
|
||||
//x, y, width and height are the item's bounding rect on the folio,
|
||||
//pos the folio cell of its top left corner, as for element.
|
||||
const QString drawing_columns(
|
||||
"uuid VARCHAR(50) PRIMARY KEY NOT NULL, "
|
||||
"diagram_uuid VARCHAR(50) NOT NULL, "
|
||||
"pos VARCHAR(6), "
|
||||
"x REAL, y REAL, width REAL, height REAL, ");
|
||||
for (const QString &table : {
|
||||
QStringLiteral("CREATE TABLE shape (") + drawing_columns +
|
||||
"type VARCHAR(20), color VARCHAR(20), fill VARCHAR(20), "
|
||||
"FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))",
|
||||
QStringLiteral("CREATE TABLE independent_text (") + drawing_columns +
|
||||
"text TEXT, rotation REAL, "
|
||||
"FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))",
|
||||
QStringLiteral("CREATE TABLE image (") + drawing_columns +
|
||||
"pixel_width INTEGER, pixel_height INTEGER, "
|
||||
"FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))",
|
||||
QStringLiteral("CREATE INDEX idx_shape_diagram ON shape (diagram_uuid)"),
|
||||
QStringLiteral("CREATE INDEX idx_independent_text_diagram ON independent_text (diagram_uuid)"),
|
||||
QStringLiteral("CREATE INDEX idx_image_diagram ON image (diagram_uuid)") })
|
||||
{
|
||||
if (!query_.exec(table)) {
|
||||
qDebug() << "drawing item table query : " << query_.lastError();
|
||||
}
|
||||
}
|
||||
|
||||
createElementNomenclatureView();
|
||||
createSummaryView();
|
||||
createWiringListView();
|
||||
createDrawingItemView();
|
||||
prepareQuery();
|
||||
updateDB();
|
||||
return true;
|
||||
@@ -934,6 +1303,32 @@ void projectDataBase::createWiringListView()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::createDrawingItemView
|
||||
One row per shape, independent text and image, whichever table holds it:
|
||||
find anything by uuid without knowing its kind first. folio is the
|
||||
folio's position in the project, starting at 1; description is the
|
||||
shape type, the text, or empty for an image.
|
||||
*/
|
||||
void projectDataBase::createDrawingItemView()
|
||||
{
|
||||
QSqlQuery query(m_data_base);
|
||||
const QString create_view(
|
||||
"CREATE VIEW drawing_item_view AS "
|
||||
"SELECT i.uuid, i.kind, d.pos AS folio, i.diagram_uuid, i.pos, "
|
||||
"i.x, i.y, i.width, i.height, i.description FROM ("
|
||||
"SELECT uuid, 'shape' AS kind, diagram_uuid, pos, x, y, width, height, "
|
||||
"type AS description FROM shape "
|
||||
"UNION ALL SELECT uuid, 'text', diagram_uuid, pos, x, y, width, height, "
|
||||
"text FROM independent_text "
|
||||
"UNION ALL SELECT uuid, 'image', diagram_uuid, pos, x, y, width, height, "
|
||||
"'' FROM image"
|
||||
") AS i LEFT JOIN diagram AS d ON d.uuid = i.diagram_uuid");
|
||||
if (!query.exec(create_view)) {
|
||||
qDebug() << query.lastError();
|
||||
}
|
||||
}
|
||||
|
||||
void projectDataBase::populateDiagramTable()
|
||||
{
|
||||
QSqlQuery query_(m_data_base);
|
||||
@@ -1119,6 +1514,23 @@ void projectDataBase::prepareQuery()
|
||||
m_remove_diagram_query = QSqlQuery(m_data_base);
|
||||
m_remove_diagram_query.prepare("DELETE FROM diagram WHERE uuid=:uuid");
|
||||
|
||||
//DRAWING ITEMS. OR REPLACE: a row is rewritten in place on every
|
||||
//change, see writeDrawingItem().
|
||||
const QString drawing_columns("uuid, diagram_uuid, pos, x, y, width, height");
|
||||
const QString drawing_values(":uuid, :diagram_uuid, :pos, :x, :y, :width, :height");
|
||||
m_insert_shape_query = QSqlQuery(m_data_base);
|
||||
m_insert_shape_query.prepare("INSERT OR REPLACE INTO shape (" + drawing_columns +
|
||||
", type, color, fill) VALUES (" + drawing_values +
|
||||
", :type, :color, :fill)");
|
||||
m_insert_independent_text_query = QSqlQuery(m_data_base);
|
||||
m_insert_independent_text_query.prepare("INSERT OR REPLACE INTO independent_text (" + drawing_columns +
|
||||
", text, rotation) VALUES (" + drawing_values +
|
||||
", :text, :rotation)");
|
||||
m_insert_image_query = QSqlQuery(m_data_base);
|
||||
m_insert_image_query.prepare("INSERT OR REPLACE INTO image (" + drawing_columns +
|
||||
", pixel_width, pixel_height) VALUES (" + drawing_values +
|
||||
", :pixel_width, :pixel_height)");
|
||||
|
||||
//INSERT DIAGRAM INFO
|
||||
m_insert_diagram_info_query = QSqlQuery(m_data_base);
|
||||
QStringList bind_diag_info_values;
|
||||
@@ -1301,23 +1713,6 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::sqliteHandle
|
||||
@param db
|
||||
@return the sqlite3 handler class used internally by db
|
||||
*/
|
||||
sqlite3 *projectDataBase::sqliteHandle(QSqlDatabase *db)
|
||||
{
|
||||
sqlite3 *handle = nullptr;
|
||||
|
||||
QVariant v = db->driver()->handle();
|
||||
if (v.isValid() && qstrcmp(v.typeName(), "sqlite3*") == 0) {
|
||||
handle = *static_cast<sqlite3 **>(v.data());
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
#ifdef QET_EXPORT_PROJECT_DB
|
||||
|
||||
/**
|
||||
@@ -1364,6 +1759,7 @@ void projectDataBase::exportDb(projectDataBase *db,
|
||||
// VACUUM INTO creates a standalone copy of the current database without
|
||||
// requiring access to the SQLite driver's native connection handle.
|
||||
const auto escaped_path = path_.replace("'", "''");
|
||||
db->flushDrawingItems();
|
||||
QSqlQuery query(db->m_data_base);
|
||||
if (!query.exec("VACUUM INTO '" % escaped_path % "'")) {
|
||||
qWarning() << "Unable to export project database:" << query.lastError().text();
|
||||
|
||||
@@ -23,13 +23,16 @@
|
||||
#include <QSqlQuery>
|
||||
#include <QPointer>
|
||||
#include <QFileDialog>
|
||||
#include <QHash>
|
||||
#include <QSet>
|
||||
#include <QUuid>
|
||||
|
||||
class Element;
|
||||
class QETProject;
|
||||
class Diagram;
|
||||
class Conductor;
|
||||
class Terminal;
|
||||
struct sqlite3;
|
||||
class QGraphicsItem;
|
||||
|
||||
/**
|
||||
@brief The projectDataBase class
|
||||
@@ -98,9 +101,17 @@ class projectDataBase : public QObject
|
||||
void removeConductor (Conductor *conductor);
|
||||
void updateConductor (Conductor *conductor);
|
||||
|
||||
//Shapes, independent texts and images: the folio's drawing
|
||||
//furniture. Anything else passed here is ignored.
|
||||
void addDrawingItem (QGraphicsItem *item);
|
||||
void removeDrawingItem (QGraphicsItem *item);
|
||||
|
||||
private slots:
|
||||
//Refresh the sender()'s row after Conductor::setProperties().
|
||||
void conductorPropertiesChanged();
|
||||
//Queue the sender()'s drawing-item row for rewriting.
|
||||
void drawingItemChanged();
|
||||
void drawingItemDestroyed(QObject *object);
|
||||
|
||||
public:
|
||||
|
||||
@@ -112,11 +123,16 @@ class projectDataBase : public QObject
|
||||
void createElementNomenclatureView();
|
||||
void createSummaryView();
|
||||
void createWiringListView();
|
||||
void createDrawingItemView();
|
||||
void populateDiagramTable();
|
||||
void populateElementTable();
|
||||
void populateElementInfoTable();
|
||||
void populateDiagramInfoTable();
|
||||
void populateConductorTable();
|
||||
void populateDrawingItemTables();
|
||||
bool writeDrawingItem(QObject *object);
|
||||
void flushDrawingItems();
|
||||
void forgetDrawingItem(QObject *object);
|
||||
void bindConductorValues(QSqlQuery &query, Conductor *conductor, Diagram *diagram);
|
||||
void watchConductor(Conductor *conductor);
|
||||
void insertTerminal(Terminal *terminal);
|
||||
@@ -154,13 +170,20 @@ class projectDataBase : public QObject
|
||||
m_cascade_remove_element_info_query,
|
||||
m_cascade_remove_terminal_query,
|
||||
m_cascade_remove_conductor_query,
|
||||
m_cascade_remove_element_query;
|
||||
m_cascade_remove_element_query,
|
||||
m_insert_shape_query,
|
||||
m_insert_independent_text_query,
|
||||
m_insert_image_query;
|
||||
|
||||
public:
|
||||
// Deliberately outside the QET_EXPORT_PROJECT_DB guard below:
|
||||
// newQuery() needs the raw connection to ask SQLite whether a
|
||||
// query only reads, and that check runs in every build.
|
||||
static sqlite3 *sqliteHandle(QSqlDatabase *db);
|
||||
//Which uuid's row each drawing item last wrote, and which item
|
||||
//wrote each row. A pasted copy is added to the folio still
|
||||
//carrying its source's uuid and renewed only afterwards, so two
|
||||
//live items can briefly share one: the row belongs to whichever
|
||||
//wrote it, and the other waits in m_dirty_drawing_items until its
|
||||
//uuid is its own. @see writeDrawingItem().
|
||||
QHash<QObject *, QUuid> m_drawing_item_row;
|
||||
QHash<QUuid, QObject *> m_drawing_row_owner;
|
||||
QSet<QObject *> m_dirty_drawing_items;
|
||||
|
||||
#ifdef QET_EXPORT_PROJECT_DB
|
||||
public:
|
||||
|
||||
@@ -18,15 +18,14 @@
|
||||
#include "sqlreadonly.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <QSqlError>
|
||||
|
||||
namespace QETSql {
|
||||
|
||||
/**
|
||||
@brief QETSql::isSingleReadOnlyStatement
|
||||
Ask SQLite itself whether @p query is exactly one statement, and whether
|
||||
that statement only reads.
|
||||
@brief QETSql::execReadOnly
|
||||
Run @p query on @p db with SQLite's query_only pragma set, so that
|
||||
SQLite itself refuses anything that would write.
|
||||
|
||||
Why SQLite is asked rather than the text inspected: a check on the
|
||||
query's first keyword cannot see what the statement actually does.
|
||||
@@ -37,98 +36,82 @@ namespace QETSql {
|
||||
WITH x AS (SELECT 1) DELETE FROM element
|
||||
@endcode
|
||||
|
||||
begins with WITH, contains no semicolon, and deletes every row.
|
||||
sqlite3_stmt_readonly() reports on the statement SQLite compiled, not
|
||||
on how it was spelled, so the same query is correctly refused here
|
||||
while an ordinary WITH ... SELECT still passes.
|
||||
begins with WITH, contains no semicolon, and deletes every row. With
|
||||
query_only set, SQLite fails that statement with SQLITE_READONLY when it
|
||||
tries to start writing, before any row is touched, while an ordinary
|
||||
WITH ... SELECT still runs.
|
||||
|
||||
The statement is compiled and immediately finalised; sqlite3_prepare_v2()
|
||||
does not run it, so nothing is executed to reach this verdict.
|
||||
Why a pragma rather than sqlite3_stmt_readonly(): that needs the
|
||||
driver's native sqlite3 handle passed to the libsqlite3 QElectroTech
|
||||
links. The QSQLITE plugin of the Qt online installer carries its own
|
||||
private copy of SQLite, so that handle belongs to another library and
|
||||
the call crashes (qelectrotech-source-mirror#1045). A pragma goes
|
||||
through the driver, whichever SQLite it uses.
|
||||
|
||||
This is a read-only test, NOT a statement-type allowlist: SQLite
|
||||
considers ATTACH, BEGIN and several PRAGMAs read-only too, because none
|
||||
of them change the contents of the database. Callers that need to
|
||||
restrict which *kind* of statement is acceptable must say so separately
|
||||
-- projectDataBase::newQuery() keeps isReadOnlySelect() in front of this
|
||||
A statement that succeeded here is read-only, so running the returned
|
||||
query again, as several callers do, runs a read-only statement again.
|
||||
A refused one comes back as an empty query with nothing to run again:
|
||||
query_only is only set for the duration of this call.
|
||||
|
||||
Qt's SQLite driver refuses a second statement after the first one, so
|
||||
"SELECT 1; DROP TABLE element" is refused too.
|
||||
|
||||
This is a read-only test, NOT a statement-type allowlist: query_only
|
||||
does not refuse ATTACH, BEGIN or most PRAGMAs, because none of them
|
||||
change the contents of the database. Callers that need to restrict
|
||||
which *kind* of statement is acceptable must say so separately --
|
||||
projectDataBase::newQuery() keeps isReadOnlySelect() in front of this
|
||||
for exactly that reason.
|
||||
|
||||
@param handle the connection the query would run on. A null handle is
|
||||
refused rather than waved through: without it there is nothing to ask,
|
||||
and guessing from the text is the weakness this exists to replace.
|
||||
@param db the connection to run the query on
|
||||
@param query the raw SQL text
|
||||
@param error set to a human-readable reason when this returns false
|
||||
@return true if @p query is a single, read-only statement
|
||||
@param error set to a human-readable reason when the query is refused
|
||||
@return the executed query, or an empty query on @p db if @p query was
|
||||
refused or failed
|
||||
*/
|
||||
bool isSingleReadOnlyStatement(sqlite3 *handle, const QString &query, QString *error)
|
||||
QSqlQuery execReadOnly(const QSqlDatabase &db, const QString &query, QString *error)
|
||||
{
|
||||
if (error) {
|
||||
error->clear();
|
||||
}
|
||||
|
||||
if (!handle) {
|
||||
if (!QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = ON"))) {
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Impossible de vérifier la requête : "
|
||||
"aucune connexion SQLite disponible.");
|
||||
"la base de données ne peut pas être mise en lecture seule.");
|
||||
}
|
||||
return false;
|
||||
return QSqlQuery(db);
|
||||
}
|
||||
|
||||
const QByteArray utf8 = query.toUtf8();
|
||||
sqlite3_stmt *statement = nullptr;
|
||||
const char *tail = nullptr;
|
||||
QSqlQuery result(db);
|
||||
const bool ok = result.exec(query);
|
||||
//Switching query_only off aborts a statement SQLite is still
|
||||
//stepping through -- an unsorted UNION ALL then ends after its
|
||||
//first row. So the checked run is finished first, and a statement
|
||||
//that passed is run again for the caller: SQLite refuses a write
|
||||
//at its first step, so passing that step proves it read-only.
|
||||
result.finish();
|
||||
QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = OFF"));
|
||||
|
||||
if (sqlite3_prepare_v2(handle, utf8.constData(), utf8.size(),
|
||||
&statement, &tail) != SQLITE_OK)
|
||||
{
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Requête SQL invalide : %1")
|
||||
.arg(QString::fromUtf8(sqlite3_errmsg(handle)));
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
return false;
|
||||
if (ok) {
|
||||
result.exec(query);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Whitespace or a bare comment compiles successfully to no statement
|
||||
// at all, and sqlite3_stmt_readonly() must not be handed that.
|
||||
if (!statement) {
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"La requête ne contient aucune instruction.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool read_only = sqlite3_stmt_readonly(statement) != 0;
|
||||
sqlite3_finalize(statement);
|
||||
|
||||
if (!read_only) {
|
||||
if (error) {
|
||||
if (error) {
|
||||
// SQLITE_READONLY is 8; extended codes keep it in the low byte.
|
||||
if ((result.lastError().nativeErrorCode().toInt() & 0xff) == 8) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Seules les requêtes en lecture seule sont autorisées : "
|
||||
"cette requête modifierait la base de données.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// tail points just past the first statement, semicolon included.
|
||||
// Anything left once semicolons and spacing are stripped is a second
|
||||
// statement -- caught structurally here, where "SELECT ';'" is a
|
||||
// perfectly ordinary query rather than a suspicious string.
|
||||
if (tail) {
|
||||
QString rest = QString::fromUtf8(tail);
|
||||
rest.remove(QLatin1Char(';'));
|
||||
if (!rest.trimmed().isEmpty()) {
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Une seule requête est autorisée.");
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Requête SQL invalide : %1")
|
||||
.arg(result.lastError().databaseText());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return QSqlQuery(db);
|
||||
}
|
||||
|
||||
} // namespace QETSql
|
||||
|
||||
@@ -18,26 +18,26 @@
|
||||
#ifndef SQLREADONLY_H
|
||||
#define SQLREADONLY_H
|
||||
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QString>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
/**
|
||||
Deciding whether a piece of SQL only reads.
|
||||
Running a piece of SQL only if it reads.
|
||||
|
||||
Deliberately its own translation unit, depending on nothing but QString
|
||||
and SQLite: it is the enforcement point for every query QElectroTech
|
||||
runs against a project database, including queries that arrive from
|
||||
outside the application (a .qet file's saved report/table query), so it
|
||||
is worth being able to test it in isolation -- see
|
||||
Deliberately its own translation unit, depending on nothing but Qt SQL:
|
||||
it is the enforcement point for every query QElectroTech runs against a
|
||||
project database, including queries that arrive from outside the
|
||||
application (a .qet file's saved report/table query), so it is worth
|
||||
being able to test it in isolation -- see
|
||||
tests/qttest/tst_sqlreadonly.cpp, which links this file and nothing
|
||||
else of QElectroTech.
|
||||
*/
|
||||
namespace QETSql {
|
||||
|
||||
bool isSingleReadOnlyStatement(sqlite3 *handle,
|
||||
const QString &query,
|
||||
QString *error = nullptr);
|
||||
QSqlQuery execReadOnly(const QSqlDatabase &db,
|
||||
const QString &query,
|
||||
QString *error = nullptr);
|
||||
}
|
||||
|
||||
#endif // SQLREADONLY_H
|
||||
|
||||
+116
-30
@@ -42,7 +42,10 @@
|
||||
#include "qetinformation.h"
|
||||
#include "qetproject.h"
|
||||
#include "diagramsortkeys.h"
|
||||
#include "textgrid.h"
|
||||
#include <QTextStream>
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <cassert>
|
||||
#include <math.h>
|
||||
|
||||
@@ -99,6 +102,43 @@ namespace {
|
||||
QString b = terminalSortKey(cond->terminal2);
|
||||
return (a <= b) ? (a + QLatin1Char('>') + b) : (b + QLatin1Char('>') + a);
|
||||
}
|
||||
|
||||
/// Serialize @p items and append them to a new @p tag block of @p root,
|
||||
/// in stacking order (@p stack_rank). items() cannot be trusted for
|
||||
/// this: with NoIndex, the first removeItem() on the scene sorts Qt's
|
||||
/// item list by pointer address, so from then on items() hands them over
|
||||
/// in a per-run order (bugtracker #343). Stacking order is what reloading
|
||||
/// the file rebuilds, so the drawing is unchanged and a resave is stable.
|
||||
template <typename T>
|
||||
void appendInStackingOrder(QDomDocument &document, QDomElement &root,
|
||||
const QString &tag, const QVector<T *> &items,
|
||||
const QHash<const QGraphicsItem *, int> &stack_rank)
|
||||
{
|
||||
if (items.isEmpty())
|
||||
return;
|
||||
struct Entry { int rank; QString xml_text; QDomElement xml; };
|
||||
QVector<Entry> sorted;
|
||||
for (T *item : items) {
|
||||
Entry entry{stack_rank.value(item, INT_MAX), QString(),
|
||||
item->toXml(document)};
|
||||
// Only an item the stacking query missed needs a tiebreak.
|
||||
if (entry.rank == INT_MAX) {
|
||||
QTextStream stream(&entry.xml_text);
|
||||
entry.xml.save(stream, 0);
|
||||
}
|
||||
sorted.append(entry);
|
||||
}
|
||||
std::stable_sort(sorted.begin(), sorted.end(),
|
||||
[](const Entry &a, const Entry &b) {
|
||||
return a.rank != b.rank ? a.rank < b.rank
|
||||
: a.xml_text < b.xml_text;
|
||||
});
|
||||
|
||||
auto block = document.createElement(tag);
|
||||
for (const auto &entry : sorted)
|
||||
block.appendChild(entry.xml);
|
||||
root.appendChild(block);
|
||||
}
|
||||
}
|
||||
|
||||
int Diagram::xGrid = 10;
|
||||
@@ -1223,37 +1263,20 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) {
|
||||
dom_root.appendChild(dom_conductors);
|
||||
}
|
||||
|
||||
if (!list_texts.isEmpty()) {
|
||||
auto dom_texts = document.createElement(QStringLiteral("inputs"));
|
||||
for (auto dti : list_texts) {
|
||||
dom_texts.appendChild(dti->toXml(document));
|
||||
}
|
||||
dom_root.appendChild(dom_texts);
|
||||
}
|
||||
|
||||
if (!list_images.isEmpty()) {
|
||||
auto dom_images = document.createElement(QStringLiteral("images"));
|
||||
for (auto dii : list_images) {
|
||||
dom_images.appendChild(dii->toXml(document));
|
||||
}
|
||||
dom_root.appendChild(dom_images);
|
||||
}
|
||||
|
||||
if (!list_shapes.isEmpty()) {
|
||||
auto dom_shapes = document.createElement(QStringLiteral("shapes"));
|
||||
for (auto dii : list_shapes) {
|
||||
dom_shapes.appendChild(dii -> toXml(document));
|
||||
}
|
||||
dom_root.appendChild(dom_shapes);
|
||||
}
|
||||
|
||||
if (table_vector.size()) {
|
||||
auto tables = document.createElement(QStringLiteral("tables"));
|
||||
for (auto table : table_vector) {
|
||||
tables.appendChild(table->toXml(document));
|
||||
}
|
||||
dom_root.appendChild(tables);
|
||||
// A rect query, unlike items(), returns true stacking order (z, then
|
||||
// insertion order) even with NoIndex.
|
||||
QHash<const QGraphicsItem *, int> stack_rank;
|
||||
{
|
||||
const QList<QGraphicsItem *> stacked = items(
|
||||
QRectF(-1e9, -1e9, 2e9, 2e9), Qt::IntersectsItemBoundingRect,
|
||||
Qt::AscendingOrder);
|
||||
for (int i = 0 ; i < stacked.size() ; ++i)
|
||||
stack_rank.insert(stacked.at(i), i);
|
||||
}
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("inputs"), list_texts, stack_rank);
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("images"), list_images, stack_rank);
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("shapes"), list_shapes, stack_rank);
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("tables"), table_vector, stack_rank);
|
||||
|
||||
if (!strip_vector.isEmpty()) {
|
||||
dom_root.appendChild(TerminalStripItemXml::toXml(strip_vector, document));
|
||||
@@ -1647,6 +1670,29 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
}
|
||||
}
|
||||
|
||||
//Texts, images and shapes written before they carried a uuid (or
|
||||
//carrying one already used on this folio, from a hand-edited file)
|
||||
//get one derived from the folio uuid, their kind and their order in
|
||||
//the file, so that loading the same file twice gives the same uuids.
|
||||
//A random one would be a different identity on every load, and
|
||||
//toXml() writes it out, which is what #754 was for conductors.
|
||||
//Only for a folio being loaded: a paste renews them all anyway.
|
||||
QSet<QUuid> used_uuids;
|
||||
auto settle_uuid = [&](auto *item, const QDomElement &xml,
|
||||
const QString &kind, int index) {
|
||||
const QUuid persisted(xml.attribute(QStringLiteral("uuid")));
|
||||
if (consider_informations
|
||||
&& (persisted.isNull() || used_uuids.contains(persisted))) {
|
||||
item->setUuid(QUuid::createUuidV5(
|
||||
m_uuid,
|
||||
QStringLiteral("%1\n%2\n%3")
|
||||
.arg(kind)
|
||||
.arg(index)
|
||||
.arg(persisted.toString())));
|
||||
}
|
||||
used_uuids.insert(item->uuid());
|
||||
};
|
||||
|
||||
// Load text
|
||||
QList<IndependentTextItem *> added_texts;
|
||||
for (auto text_xml : QET::findInDomElement(root,
|
||||
@@ -1654,6 +1700,7 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
QStringLiteral("input"))) {
|
||||
IndependentTextItem *iti = new IndependentTextItem();
|
||||
iti -> fromXml(text_xml);
|
||||
settle_uuid(iti, text_xml, QStringLiteral("input"), added_texts.size());
|
||||
addItem(iti);
|
||||
added_texts << iti;
|
||||
}
|
||||
@@ -1665,6 +1712,7 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
QStringLiteral("image"))) {
|
||||
DiagramImageItem *dii = new DiagramImageItem ();
|
||||
dii -> fromXml(image_xml);
|
||||
settle_uuid(dii, image_xml, QStringLiteral("image"), added_images.size());
|
||||
addItem(dii);
|
||||
added_images << dii;
|
||||
}
|
||||
@@ -1676,6 +1724,7 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
QStringLiteral("shape"))) {
|
||||
QetShapeItem *dii = new QetShapeItem (QPointF(0,0));
|
||||
dii -> fromXml(shape_xml);
|
||||
settle_uuid(dii, shape_xml, QStringLiteral("shape"), added_shapes.size());
|
||||
addItem(dii);
|
||||
added_shapes << dii;
|
||||
}
|
||||
@@ -1869,6 +1918,13 @@ void Diagram::addItem(QGraphicsItem *item)
|
||||
m_project->dataBase()->addConductor(conductor);
|
||||
break;
|
||||
}
|
||||
case QetShapeItem::Type:
|
||||
case IndependentTextItem::Type:
|
||||
case DiagramImageItem::Type:
|
||||
{
|
||||
m_project->dataBase()->addDrawingItem(item);
|
||||
break;
|
||||
}
|
||||
default: {break;}
|
||||
}
|
||||
}
|
||||
@@ -1900,6 +1956,13 @@ void Diagram::removeItem(QGraphicsItem *item)
|
||||
m_project->dataBase()->removeConductor(conductor);
|
||||
break;
|
||||
}
|
||||
case QetShapeItem::Type:
|
||||
case IndependentTextItem::Type:
|
||||
case DiagramImageItem::Type:
|
||||
{
|
||||
m_project->dataBase()->removeDrawingItem(item);
|
||||
break;
|
||||
}
|
||||
default: {break;}
|
||||
}
|
||||
|
||||
@@ -2662,6 +2725,29 @@ QPointF Diagram::snapToGrid(const QPointF &p)
|
||||
return (QPointF(p_x, p_y));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Diagram::snapToTextGrid
|
||||
Return the nearest point of p on the text grid, see TextGrid.
|
||||
Ctrl held rounds to the nearest pixel instead, as snapToGrid() does.
|
||||
@param p point to find the nearest snapped point
|
||||
@return
|
||||
*/
|
||||
QPointF Diagram::snapToTextGrid(const QPointF &p)
|
||||
{
|
||||
QSettings settings;
|
||||
const qreal divisor =
|
||||
QApplication::keyboardModifiers().testFlag(Qt::ControlModifier)
|
||||
? 0
|
||||
: settings.value(TextGrid::settings_key, 1).toReal();
|
||||
|
||||
return TextGrid::snap(p,
|
||||
settings.value(QStringLiteral("diagrameditor/Xgrid"),
|
||||
Diagram::xGrid).toInt(),
|
||||
settings.value(QStringLiteral("diagrameditor/Ygrid"),
|
||||
Diagram::yGrid).toInt(),
|
||||
divisor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -233,6 +233,7 @@ class Diagram : public QGraphicsScene
|
||||
BorderOptions borderOptions();
|
||||
DiagramPosition convertPosition(const QPointF &);
|
||||
static QPointF snapToGrid(const QPointF &p);
|
||||
static QPointF snapToTextGrid(const QPointF &p);
|
||||
|
||||
bool drawTerminals() const;
|
||||
void setDrawTerminals(bool);
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
|
||||
#include "diagram.h"
|
||||
#include "qetgraphicsitem/conductortextitem.h"
|
||||
#include "qetgraphicsitem/diagramimageitem.h"
|
||||
#include "qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "qetgraphicsitem/element.h"
|
||||
#include "qetgraphicsitem/elementtextitemgroup.h"
|
||||
#include "qetgraphicsitem/independenttextitem.h"
|
||||
#include "qetgraphicsitem/qetshapeitem.h"
|
||||
#include "qetinformation.h"
|
||||
#include "qgimanager.h"
|
||||
|
||||
@@ -100,6 +103,15 @@ void PasteDiagramCommand::redo()
|
||||
for (Conductor *c : all_pasted_conductors) {
|
||||
c -> newUuid();
|
||||
}
|
||||
for (IndependentTextItem *t : std::as_const(content.m_text_fields)) {
|
||||
t -> newUuid();
|
||||
}
|
||||
for (DiagramImageItem *i : std::as_const(content.m_images)) {
|
||||
i -> newUuid();
|
||||
}
|
||||
for (QetShapeItem *s : std::as_const(content.m_shapes)) {
|
||||
s -> newUuid();
|
||||
}
|
||||
|
||||
//this is the first paste, we do some actions for the new element
|
||||
for (Element *e : elmts_list)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagramcontexttoolbar.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QEvent>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMouseEvent>
|
||||
#include <QSettings>
|
||||
#include <QToolButton>
|
||||
|
||||
namespace {
|
||||
/// Fully opaque within this distance of the toolbar, in pixels...
|
||||
const int opaque_distance = 30;
|
||||
/// ...fading to hidden at this one
|
||||
const int hide_distance = 200;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::DiagramContextToolbar
|
||||
@param viewport : the view's viewport, which it is drawn on and watches
|
||||
*/
|
||||
DiagramContextToolbar::DiagramContextToolbar(QWidget *viewport) :
|
||||
QFrame(viewport)
|
||||
{
|
||||
setFrameShape(QFrame::StyledPanel);
|
||||
setAutoFillBackground(true);
|
||||
setFocusPolicy(Qt::NoFocus);
|
||||
|
||||
m_layout = new QHBoxLayout(this);
|
||||
m_layout->setContentsMargins(2, 2, 2, 2);
|
||||
m_layout->setSpacing(1);
|
||||
|
||||
m_opacity = new QGraphicsOpacityEffect(this);
|
||||
setGraphicsEffect(m_opacity);
|
||||
hide();
|
||||
|
||||
viewport->installEventFilter(this);
|
||||
//Key presses go to the view, not its viewport
|
||||
if (viewport->parentWidget()) {
|
||||
viewport->parentWidget()->installEventFilter(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@return whether the toolbar should be shown at all (a preference)
|
||||
*/
|
||||
bool DiagramContextToolbar::isEnabled()
|
||||
{
|
||||
return QSettings().value(QStringLiteral("diagrameditor/context_toolbar"),
|
||||
true).toBool();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::showAt
|
||||
Show @a actions just above and to the right of @a viewport_pos, kept
|
||||
inside the viewport. Clicking a button triggers the action and leaves
|
||||
the toolbar up, so a command such as rotate can be clicked again.
|
||||
*/
|
||||
void DiagramContextToolbar::showAt(const QPoint &viewport_pos,
|
||||
const QList<QAction *> &actions)
|
||||
{
|
||||
while (QLayoutItem *item = m_layout->takeAt(0)) {
|
||||
delete item->widget();
|
||||
delete item;
|
||||
}
|
||||
for (QAction *action : actions.mid(0, max_actions))
|
||||
{
|
||||
auto *button = new QToolButton(this);
|
||||
button->setAutoRaise(true);
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
button->setIconSize(QSize(20, 20));
|
||||
const QString text = action->text().remove(QLatin1Char('&'));
|
||||
if (action->icon().isNull()) {
|
||||
button->setText(text);
|
||||
} else {
|
||||
button->setIcon(action->icon());
|
||||
}
|
||||
button->setToolTip(text);
|
||||
button->setEnabled(action->isEnabled());
|
||||
connect(button, &QToolButton::clicked, action, &QAction::trigger);
|
||||
m_layout->addWidget(button);
|
||||
}
|
||||
if (actions.isEmpty()) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
adjustSize();
|
||||
const QRect area = parentWidget()->rect();
|
||||
QPoint pos = viewport_pos + QPoint(16, -height() - 16);
|
||||
pos.setX(qBound(area.left(), pos.x(), area.right() - width()));
|
||||
pos.setY(qBound(area.top(), pos.y(), area.bottom() - height()));
|
||||
move(pos);
|
||||
m_opacity->setOpacity(1.0);
|
||||
show();
|
||||
raise();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::followMouse
|
||||
Fade with the mouse's distance from the toolbar, and hide past
|
||||
hide_distance.
|
||||
*/
|
||||
void DiagramContextToolbar::followMouse(const QPoint &viewport_pos)
|
||||
{
|
||||
const QRect r = geometry();
|
||||
const int dx = qMax(qMax(r.left() - viewport_pos.x(), 0), viewport_pos.x() - r.right());
|
||||
const int dy = qMax(qMax(r.top() - viewport_pos.y(), 0), viewport_pos.y() - r.bottom());
|
||||
const int distance = qMax(dx, dy);
|
||||
|
||||
if (distance >= hide_distance) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const qreal fade = qreal(distance - opaque_distance)
|
||||
/ (hide_distance - opaque_distance);
|
||||
m_opacity->setOpacity(qBound(0.15, 1.0 - fade, 1.0));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::eventFilter
|
||||
Watch the viewport and the view while shown: follow the mouse, and hide
|
||||
on a press elsewhere, a wheel turn or a key press.
|
||||
*/
|
||||
bool DiagramContextToolbar::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
Q_UNUSED(watched)
|
||||
if (!isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (event->type())
|
||||
{
|
||||
case QEvent::MouseMove:
|
||||
followMouse(static_cast<QMouseEvent *>(event)->position().toPoint());
|
||||
break;
|
||||
case QEvent::MouseButtonPress:
|
||||
case QEvent::Wheel:
|
||||
case QEvent::KeyPress:
|
||||
case QEvent::Leave:
|
||||
hide();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DIAGRAMCONTEXTTOOLBAR_H
|
||||
#define DIAGRAMCONTEXTTOOLBAR_H
|
||||
|
||||
#include <QFrame>
|
||||
#include <QList>
|
||||
|
||||
class QAction;
|
||||
class QGraphicsOpacityEffect;
|
||||
class QHBoxLayout;
|
||||
|
||||
/**
|
||||
@brief A small row of commands shown beside the cursor after a click
|
||||
selects something on a folio, as SolidWorks does.
|
||||
|
||||
It lives on the view's viewport, so it never takes the focus or opens a
|
||||
window. It fades as the mouse moves away and hides once the mouse is far
|
||||
enough, on a click elsewhere, a wheel turn or a key press. The commands
|
||||
are the shortcut bar's for the selection (ShortcutBarSettings).
|
||||
*/
|
||||
class DiagramContextToolbar : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DiagramContextToolbar(QWidget *viewport);
|
||||
|
||||
void showAt(const QPoint &viewport_pos, const QList<QAction *> &actions);
|
||||
static bool isEnabled();
|
||||
|
||||
/// At most this many commands, so it stays small
|
||||
static const int max_actions = 8;
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void followMouse(const QPoint &viewport_pos);
|
||||
|
||||
QHBoxLayout *m_layout = nullptr;
|
||||
QGraphicsOpacityEffect *m_opacity = nullptr;
|
||||
};
|
||||
|
||||
#endif // DIAGRAMCONTEXTTOOLBAR_H
|
||||
@@ -117,7 +117,16 @@ void DiagramEventAddImage::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
else if (m_image && !m_pressed && event->button() == Qt::RightButton)
|
||||
{
|
||||
m_image->setRotation(m_image->rotation() + 90);
|
||||
// rotationAngle()/setRotationAngle(), not QGraphicsItem's own
|
||||
// rotation()/setRotation(): DiagramImageItem's whole handle/undo/
|
||||
// XML-save machinery reads exclusively from its own m_transform
|
||||
// (see diagramimageitem.cpp's toXml() comment) and never looks at
|
||||
// QGraphicsItem's built-in convenience property at all -- using
|
||||
// it here left a rotation that displayed correctly in this tool
|
||||
// but silently vanished the moment the item was saved and
|
||||
// reloaded, and additionally desynced the rotate-handle's pivot
|
||||
// math once the image was later selected for editing.
|
||||
m_image->setRotationAngle(m_image->rotationAngle() + 90);
|
||||
event->setAccepted(true);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +176,22 @@ void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
else
|
||||
{
|
||||
// setPivot(QPointF(0, 0)), not left at its default
|
||||
// boundingRect().center(): DiagramImageItem's own m_transform
|
||||
// is pivot-centered, so with the default center pivot the
|
||||
// setPos(qMin(...)) below would no longer land on the image's
|
||||
// actual top-left corner the moment scale != 1 (the corner
|
||||
// only coincides with pos() when the pivot is the local
|
||||
// origin). Anchoring here to (0, 0) -- exactly the same
|
||||
// temporary-anchor trick DiagramImageItem::handlerMousePressEvent()
|
||||
// already uses for its own Resize handles -- makes pos()
|
||||
// keep meaning "scene position of the top-left corner"
|
||||
// regardless of scale, so the qMin(...) line below still
|
||||
// needs no change at all. setPivot() itself is a no-op past
|
||||
// the first call (same pivot value), and compensates pos()
|
||||
// automatically so nothing visibly jumps at the switch.
|
||||
m_image->setPivot(QPointF(0, 0));
|
||||
|
||||
const QSizeF naturalSize = m_image->boundingRect().size();
|
||||
if (naturalSize.width() > 0 && naturalSize.height() > 0)
|
||||
{
|
||||
@@ -179,7 +204,12 @@ void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
// free-form one -- breaking aspect ratio on purpose is
|
||||
// its own, separate, larger piece of work.
|
||||
const qreal newScale = qBound(0.01, qMax(scaleX, scaleY), 50.0);
|
||||
m_image->setScale(newScale);
|
||||
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own
|
||||
// scale(): see the mousePressEvent right-click rotate
|
||||
// comment above for why -- identical reasoning, identical
|
||||
// fix.
|
||||
m_image->setScaleFactorX(newScale);
|
||||
m_image->setScaleFactorY(newScale);
|
||||
}
|
||||
m_image->setPos(qMin(m_press_pos.x(), pos.x()), qMin(m_press_pos.y(), pos.y()));
|
||||
}
|
||||
@@ -206,6 +236,17 @@ void DiagramEventAddImage::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
if (m_image && m_pressed && event->button() == Qt::LeftButton)
|
||||
{
|
||||
// Undo whatever temporary origin-anchoring mouseMoveEvent's
|
||||
// resize-drag branch did (a no-op if it never engaged): every
|
||||
// other DiagramImageItem code path -- handles, undo, XML save --
|
||||
// expects an image's pivot to be its bounding-rect center unless
|
||||
// the user deliberately customized it, exactly like a completed
|
||||
// handle-resize already resets it via resetPivotToBoundingRectCenter().
|
||||
// Doing this before reading pos() below is what makes the pushed
|
||||
// command capture the final, center-pivot position rather than
|
||||
// the drag's temporary corner-anchored one.
|
||||
m_image->setPivot(m_image->boundingRect().center());
|
||||
|
||||
m_diagram->undoStack().push(new AddGraphicsObjectCommand(m_image, m_diagram, m_image->pos()));
|
||||
|
||||
for (QGraphicsView *view : m_diagram->views()) {
|
||||
@@ -246,10 +287,32 @@ void DiagramEventAddImage::wheelEvent(QGraphicsSceneWheelEvent *event)
|
||||
return;
|
||||
}
|
||||
|
||||
qreal scaling = m_image->scale();
|
||||
event->delta() > 1? scaling += 0.01 : scaling -= 0.01;
|
||||
if (scaling>0.01 && scaling <= 2) {
|
||||
m_image->setScale(scaling);
|
||||
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own scale(): see
|
||||
// the right-click rotate comment in mousePressEvent for why. Wheel-
|
||||
// scaling only ever runs while !m_pressed (guarded above), i.e. before
|
||||
// any drag-resize has anchored the pivot to the origin (see
|
||||
// mouseMoveEvent), so the pivot here is still the default
|
||||
// boundingRect().center() and this scales the image in place around
|
||||
// its own middle, exactly like before.
|
||||
//
|
||||
// Step each axis from its own current value rather than reading X and
|
||||
// writing it back to both: scaleFactorX and scaleFactorY cannot
|
||||
// actually differ at this point today (every other mutator in this
|
||||
// class -- the drag-resize branch above, and this same wheelEvent --
|
||||
// only ever sets them to the same value, and mouseReleaseEvent commits
|
||||
// and ends this tool on any left-button release, so a handle-based
|
||||
// non-uniform resize can never happen first and leave this instance
|
||||
// still alive). Stepping both from their own value rather than
|
||||
// collapsing Y to X costs nothing today and removes the trap if that
|
||||
// invariant ever stops holding.
|
||||
qreal scalingX = m_image->scaleFactorX();
|
||||
qreal scalingY = m_image->scaleFactorY();
|
||||
const qreal step = event->delta() > 1 ? 0.01 : -0.01;
|
||||
scalingX += step;
|
||||
scalingY += step;
|
||||
if (scalingX > 0.01 && scalingX <= 2 && scalingY > 0.01 && scalingY <= 2) {
|
||||
m_image->setScaleFactorX(scalingX);
|
||||
m_image->setScaleFactorY(scalingY);
|
||||
}
|
||||
|
||||
event->setAccepted(true);
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
DiagramEventAddPaste::DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos) :
|
||||
DiagramEventInterface(diagram)
|
||||
{
|
||||
Q_UNUSED(start_pos); // items stay at their original XML position
|
||||
//DiagramEventInterface::init() is called by Diagram::setEventInterface
|
||||
//only when it is replacing an earlier interface, so call it here as
|
||||
//DiagramEventAddMacro does.
|
||||
@@ -77,21 +76,26 @@
|
||||
const QList<QGraphicsItem *> movable = m_content.items(MovableItems);
|
||||
if (movable.isEmpty()) return;
|
||||
|
||||
//Compute the top-left of all items' positions (not bounding
|
||||
//rects) and snap to grid: this is the point that gets placed
|
||||
//under the cursor, and the baseline moveTo() measures from.
|
||||
QPointF top_left;
|
||||
bool first = true;
|
||||
//Compute the top-left of all items' actual on-screen bounding
|
||||
//boxes (not their raw pos()) and snap to grid: this is the point
|
||||
//that gets placed under the cursor, and the baseline moveTo()
|
||||
//measures from. mapToScene(boundingRect()) matters here, not
|
||||
//pos() alone: pos() is the scene location of an item's local
|
||||
//origin, but for anything with a pivot-centered transform (a
|
||||
//scaled or rotated image, in particular) that origin can sit far
|
||||
//from where the item is actually drawn -- pivot + scale*(0 -
|
||||
//pivot) is nowhere near (0, 0) once scale is well under 1. Using
|
||||
//pos() here silently pasted content at the right *delta* from a
|
||||
//point that wasn't actually where the content visually was,
|
||||
//producing a constant, scale-dependent offset between the cursor
|
||||
//and the pasted picture. Diagram::fromXml()'s own position
|
||||
//parameter already gets this right the same way, for the same
|
||||
//reason.
|
||||
QRectF items_rect;
|
||||
for (auto *item : movable) {
|
||||
const QPointF p = item->pos();
|
||||
if (first) {
|
||||
top_left = p;
|
||||
first = false;
|
||||
} else {
|
||||
if (p.x() < top_left.x()) top_left.setX(p.x());
|
||||
if (p.y() < top_left.y()) top_left.setY(p.y());
|
||||
}
|
||||
items_rect = items_rect.united(item->mapToScene(item->boundingRect()).boundingRect());
|
||||
}
|
||||
const QPointF top_left = items_rect.topLeft();
|
||||
QSettings settings;
|
||||
const int xGrid = settings.value(QStringLiteral("diagrameditor/Xgrid"),
|
||||
Diagram::xGrid).toInt();
|
||||
@@ -102,9 +106,24 @@
|
||||
qRound(p.x() / xGrid) * xGrid,
|
||||
qRound(p.y() / yGrid) * yGrid);
|
||||
};
|
||||
const QPointF grid_origin = snapGrid(top_left);
|
||||
const QPointF grid_origin = snapGrid(start_pos);
|
||||
|
||||
//Store each item's original position. moveTo() applies a
|
||||
//Land the pasted content under the cursor immediately, rather than
|
||||
//leaving it at the copied source's own coordinates: fromXml() above
|
||||
//loads items at their original position purely because it doesn't
|
||||
//know the target yet, not because that is where a paste should end
|
||||
//up. The previous approach instead left items there and warped the
|
||||
//OS cursor to match -- QCursor::setPos() is silently ignored by
|
||||
//many window managers and compositors (Wayland in particular), so
|
||||
//on any of those the warp simply never happened and the paste was
|
||||
//left wherever it had originally been copied from, which could be
|
||||
//anywhere on the folio -- exactly the "far from the cursor" bug.
|
||||
const QPointF initial_delta = grid_origin - snapGrid(top_left);
|
||||
for (auto *item : movable) {
|
||||
item->setPos(item->pos() + initial_delta);
|
||||
}
|
||||
|
||||
//Store each item's now-placed position. moveTo() applies a
|
||||
//grid-snapped delta from the baseline to these, so items
|
||||
//preserve their layout and move in whole grid steps.
|
||||
for (auto *item : movable) {
|
||||
@@ -134,14 +153,6 @@
|
||||
if (const auto qde = QETApp::diagramEditorAncestorOf(view)) {
|
||||
m_status_bar = qde->statusBar();
|
||||
}
|
||||
//Warp the cursor to the group's grid-snapped origin so
|
||||
//the actual cursor position matches m_initial_cursor.
|
||||
//Without this the first mouseMoveEvent computes a large
|
||||
//delta (cursor is still at the Ctrl+V press location)
|
||||
//and the items jump on first touch.
|
||||
const QPoint view_pos = view->mapFromScene(m_initial_cursor);
|
||||
const QPoint global_pos = view->viewport()->mapToGlobal(view_pos);
|
||||
QCursor::setPos(global_pos);
|
||||
}
|
||||
}
|
||||
showHint();
|
||||
|
||||
@@ -84,7 +84,10 @@ void DiagramEventAddPdf::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
else if (m_image && event->button() == Qt::RightButton)
|
||||
{
|
||||
m_image->setRotation(m_image->rotation() + 90);
|
||||
// rotationAngle()/setRotationAngle(), not QGraphicsItem's own
|
||||
// rotation()/setRotation(): see DiagramEventAddImage's identical
|
||||
// fix (mousePressEvent) for why -- same class, same reasoning.
|
||||
m_image->setRotationAngle(m_image->rotationAngle() + 90);
|
||||
event->setAccepted(true);
|
||||
}
|
||||
}
|
||||
@@ -132,14 +135,35 @@ void DiagramEventAddPdf::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
|
||||
*/
|
||||
void DiagramEventAddPdf::wheelEvent(QGraphicsSceneWheelEvent *event)
|
||||
{
|
||||
if (!m_is_added || !m_image || event->modifiers() != Qt::CTRL) {
|
||||
// event->modifiers() & Qt::ControlModifier, not != Qt::CTRL: the same
|
||||
// exact-equality bug already found and fixed elsewhere this session --
|
||||
// Ctrl held together with any other modifier would silently fail to
|
||||
// register as Ctrl at all.
|
||||
if (!m_is_added || !m_image || !(event->modifiers() & Qt::ControlModifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
qreal scaling = m_image->scale();
|
||||
event->delta() > 1 ? scaling += 0.01 : scaling -= 0.01;
|
||||
if (scaling > 0.01 && scaling <= 2) {
|
||||
m_image->setScale(scaling);
|
||||
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own scale(): see
|
||||
// DiagramEventAddImage's identical fix for why. No drag-to-resize
|
||||
// exists here, and the pivot is never touched elsewhere in this
|
||||
// class, so it stays at its default boundingRect().center() and this
|
||||
// scales the page in place around its own middle, exactly like
|
||||
// before.
|
||||
//
|
||||
// Step each axis from its own current value rather than reading X and
|
||||
// writing it back to both: scaleFactorX and scaleFactorY cannot
|
||||
// actually differ here today (nothing in this class ever sets them to
|
||||
// different values, and there is no drag-resize at all), but stepping
|
||||
// both independently costs nothing and removes the trap if that ever
|
||||
// changes.
|
||||
qreal scalingX = m_image->scaleFactorX();
|
||||
qreal scalingY = m_image->scaleFactorY();
|
||||
const qreal step = event->delta() > 1 ? 0.01 : -0.01;
|
||||
scalingX += step;
|
||||
scalingY += step;
|
||||
if (scalingX > 0.01 && scalingX <= 2 && scalingY > 0.01 && scalingY <= 2) {
|
||||
m_image->setScaleFactorX(scalingX);
|
||||
m_image->setScaleFactorY(scalingY);
|
||||
}
|
||||
|
||||
event->setAccepted(true);
|
||||
|
||||
@@ -87,9 +87,9 @@ DiagramEventAddShape::~DiagramEventAddShape()
|
||||
Applies a drag/click position to the in-progress shape, honouring two
|
||||
modifiers that mirror how the very same shape can already be edited
|
||||
afterward, once placed:
|
||||
- Ctrl, for Rectangle/Ellipse only: the first click becomes the
|
||||
shape's *center* rather than a corner, growing symmetrically as
|
||||
the cursor moves away from it -- the same meaning Ctrl already
|
||||
- Ctrl, for Rectangle/Ellipse only: the first click's point acts as
|
||||
the shape's *center* rather than a corner, growing symmetrically
|
||||
as the cursor moves away from it -- the same meaning Ctrl already
|
||||
has on a Resize handle (anchor at center). Deliberately not
|
||||
offered for Line: unlike the Rectangle/Ellipse case, there's no
|
||||
established convention for "a line grows symmetrically from its
|
||||
@@ -100,12 +100,18 @@ DiagramEventAddShape::~DiagramEventAddShape()
|
||||
dragged dimensions is currently larger and mirroring that onto
|
||||
the other, preserving the direction the user is actually
|
||||
dragging in.
|
||||
Both can combine (Ctrl+Shift: a centered square/circle). Whether or
|
||||
not Ctrl is currently held, the non-anchored branch always rebuilds
|
||||
from m_anchor_point rather than nudging the existing rect/line --
|
||||
otherwise, if Ctrl had been held earlier in the same drag (moving the
|
||||
shape's own first point to a mirrored position), releasing it would
|
||||
leave that point stuck there instead of actually restoring it.
|
||||
Both can combine (Ctrl+Shift: a centered square/circle). Whether Ctrl
|
||||
currently anchors from the center is re-decided on every call, from
|
||||
the live modifiers passed in here -- not frozen at whatever was held
|
||||
on the first click. Every comparable tool (Illustrator, Photoshop,
|
||||
Figma, Inkscape...) lets you press or release the center-origin
|
||||
modifier at any point mid-drag, with the shape immediately jumping to
|
||||
match; that jump is the expected feedback for changing which point is
|
||||
anchored, not a glitch. Freezing the choice at the first click instead
|
||||
meant holding Ctrl anywhere other than the initial mouse-down did
|
||||
nothing visible -- which, tried the more natural way (drag first,
|
||||
then reach for Ctrl once you decide you want it centered), read as
|
||||
"Ctrl doesn't work" rather than as a deliberate one-shot decision.
|
||||
*/
|
||||
void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifiers mods)
|
||||
{
|
||||
@@ -125,14 +131,13 @@ void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifie
|
||||
return;
|
||||
}
|
||||
|
||||
// m_center_anchored is decided once, in mousePressEvent, not
|
||||
// re-checked here on every call -- re-checking it live meant
|
||||
// releasing Ctrl mid-drag (something you'd naturally do the moment
|
||||
// your hand gets tired holding it, long before you're done resizing)
|
||||
// silently snapped the shape back to corner-anchored, discarding
|
||||
// what felt like an already-made decision. Deciding it once at the
|
||||
// first click matches "I held Ctrl when I clicked, so this shape is
|
||||
// centered" -- a single, predictable rule instead of a live toggle.
|
||||
m_center_anchored = (mods & Qt::ControlModifier)
|
||||
&& (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse);
|
||||
if (m_center_anchored)
|
||||
showCenterMarker(m_anchor_point);
|
||||
else
|
||||
hideCenterMarker();
|
||||
|
||||
QPointF target = pos;
|
||||
|
||||
if ((mods & Qt::ShiftModifier)
|
||||
@@ -218,10 +223,9 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
m_shape_item = new QetShapeItem(pos, pos, m_shape_type);
|
||||
m_anchor_point = pos;
|
||||
// Decided once, here, rather than re-checked on every mouse
|
||||
// move for the rest of the drag -- see applyPosition()'s doc
|
||||
// comment for why continuous re-checking made releasing Ctrl
|
||||
// mid-drag feel like a bug rather than a deliberate choice.
|
||||
// Initial feedback only -- applyPosition() re-decides this
|
||||
// live on every subsequent move, from whatever Ctrl state is
|
||||
// held at the time.
|
||||
m_center_anchored = (event->modifiers() & Qt::ControlModifier)
|
||||
&& (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse);
|
||||
if (m_center_anchored)
|
||||
|
||||
@@ -59,7 +59,7 @@ class DiagramEventAddShape : public DiagramEventInterface
|
||||
QGraphicsLineItem *m_help_horiz, *m_help_verti;
|
||||
QPointF m_anchor_point; // the shape's first-click point -- meaningful once m_shape_item exists
|
||||
QGraphicsEllipseItem *m_center_marker = nullptr; // shown only while Ctrl-anchoring is actually in effect, so it doubles as confirmation that it is
|
||||
bool m_center_anchored = false; // decided once, at the first click -- see applyPosition()'s doc comment for why
|
||||
bool m_center_anchored = false; // re-decided live on every applyPosition() call, from current Ctrl state
|
||||
QPointF m_last_mouse_scene_pos; // raw, unsnapped -- lets a modifier-only change re-snap correctly when reapplied
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagramgestureoverlay.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QSettings>
|
||||
#include <QtMath>
|
||||
|
||||
namespace {
|
||||
const int outer_radius = 84;
|
||||
/// Releasing closer than this to the centre cancels
|
||||
const int inner_radius = 22;
|
||||
const int icon_radius = 56;
|
||||
const int icon_size = 22;
|
||||
/// Wider than the ring, so a long command name under it is not cut
|
||||
const int overlay_width = 280;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramGestureOverlay::DiagramGestureOverlay
|
||||
@param viewport : the view's viewport, which it is drawn on. It lets the
|
||||
mouse through, so the view keeps receiving the drag.
|
||||
*/
|
||||
DiagramGestureOverlay::DiagramGestureOverlay(QWidget *viewport) :
|
||||
QWidget(viewport)
|
||||
{
|
||||
setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setFixedSize(overlay_width, 2 * outer_radius + 2 + 24);
|
||||
hide();
|
||||
}
|
||||
|
||||
/**
|
||||
@return whether right-drag gestures are on (a preference)
|
||||
*/
|
||||
bool DiagramGestureOverlay::isEnabled()
|
||||
{
|
||||
return QSettings().value(QStringLiteral("diagrameditor/mouse_gestures"),
|
||||
true).toBool();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramGestureOverlay::showAt
|
||||
Show the ring centred on @a center with @a actions, at most eight,
|
||||
clockwise from the top.
|
||||
*/
|
||||
void DiagramGestureOverlay::showAt(const QPoint ¢er,
|
||||
const QList<QAction *> &actions)
|
||||
{
|
||||
m_center = center;
|
||||
m_actions = actions.mid(0, sectors);
|
||||
m_active = -1;
|
||||
move(center - QPoint(overlay_width / 2, outer_radius + 1));
|
||||
show();
|
||||
raise();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramGestureOverlay::sectorAt
|
||||
@return the sector in the direction of @a viewport_pos from the centre,
|
||||
0 at the top then clockwise, or -1 within the cancel radius
|
||||
*/
|
||||
int DiagramGestureOverlay::sectorAt(const QPoint &viewport_pos) const
|
||||
{
|
||||
const QPoint d = viewport_pos - m_center;
|
||||
if (qHypot(d.x(), d.y()) < inner_radius) {
|
||||
return -1;
|
||||
}
|
||||
//Angle clockwise from the top, in degrees
|
||||
qreal angle = qRadiansToDegrees(qAtan2(d.x(), -d.y()));
|
||||
if (angle < 0) {
|
||||
angle += 360;
|
||||
}
|
||||
return int(qRound(angle / (360.0 / sectors))) % sectors;
|
||||
}
|
||||
|
||||
/**
|
||||
@return the command in the direction of @a viewport_pos, or nullptr when
|
||||
there is none there or it is disabled
|
||||
*/
|
||||
QAction *DiagramGestureOverlay::actionAt(const QPoint &viewport_pos) const
|
||||
{
|
||||
const int sector = sectorAt(viewport_pos);
|
||||
if (sector < 0 || sector >= m_actions.count()) {
|
||||
return nullptr;
|
||||
}
|
||||
QAction *action = m_actions.at(sector);
|
||||
return action->isEnabled() ? action : nullptr;
|
||||
}
|
||||
|
||||
void DiagramGestureOverlay::setPointer(const QPoint &viewport_pos)
|
||||
{
|
||||
const int sector = sectorAt(viewport_pos);
|
||||
if (sector != m_active) {
|
||||
m_active = sector;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
void DiagramGestureOverlay::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
const QPointF c(overlay_width / 2, outer_radius + 1);
|
||||
const qreal step = 360.0 / sectors;
|
||||
|
||||
QColor base = palette().color(QPalette::Window);
|
||||
base.setAlpha(225);
|
||||
painter.setPen(palette().color(QPalette::Mid));
|
||||
painter.setBrush(base);
|
||||
painter.drawEllipse(c, outer_radius, outer_radius);
|
||||
|
||||
//Highlight the sector the mouse points at, if it holds a command
|
||||
if (m_active >= 0 && m_active < m_actions.count()
|
||||
&& m_actions.at(m_active)->isEnabled())
|
||||
{
|
||||
QPainterPath wedge;
|
||||
wedge.moveTo(c);
|
||||
//Qt angles run counter-clockwise from 3 o'clock
|
||||
const qreal start = 90 - m_active * step - step / 2;
|
||||
wedge.arcTo(QRectF(c.x() - outer_radius, c.y() - outer_radius,
|
||||
2 * outer_radius, 2 * outer_radius), start, step);
|
||||
wedge.closeSubpath();
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(palette().color(QPalette::Highlight));
|
||||
painter.drawPath(wedge);
|
||||
}
|
||||
|
||||
painter.setPen(palette().color(QPalette::Mid));
|
||||
painter.setBrush(palette().color(QPalette::Window));
|
||||
painter.drawEllipse(c, inner_radius, inner_radius);
|
||||
|
||||
for (int i = 0 ; i < m_actions.count() ; ++i)
|
||||
{
|
||||
QAction *action = m_actions.at(i);
|
||||
const qreal a = qDegreesToRadians(i * step);
|
||||
const QPointF p(c.x() + icon_radius * qSin(a), c.y() - icon_radius * qCos(a));
|
||||
const QRect r(int(p.x()) - icon_size / 2, int(p.y()) - icon_size / 2,
|
||||
icon_size, icon_size);
|
||||
const QIcon::Mode mode = action->isEnabled() ? QIcon::Normal : QIcon::Disabled;
|
||||
if (!action->icon().isNull()) {
|
||||
action->icon().paint(&painter, r, Qt::AlignCenter, mode);
|
||||
} else {
|
||||
painter.setPen(palette().color(action->isEnabled() ? QPalette::Active : QPalette::Disabled,
|
||||
QPalette::WindowText));
|
||||
painter.drawText(r.adjusted(-12, 0, 12, 0), Qt::AlignCenter,
|
||||
action->text().remove(QLatin1Char('&')).left(3));
|
||||
}
|
||||
}
|
||||
|
||||
//Name of the highlighted command, under the ring
|
||||
if (m_active >= 0 && m_active < m_actions.count())
|
||||
{
|
||||
const QString name = m_actions.at(m_active)->text().remove(QLatin1Char('&'));
|
||||
const QRect label(0, 2 * outer_radius + 4, width(), 20);
|
||||
QFont f = font();
|
||||
f.setBold(true);
|
||||
painter.setFont(f);
|
||||
const QRect text = painter.fontMetrics().boundingRect(label, Qt::AlignCenter, name)
|
||||
.adjusted(-6, -2, 6, 2);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(base);
|
||||
painter.drawRoundedRect(text, 4, 4);
|
||||
painter.setPen(palette().color(QPalette::WindowText));
|
||||
painter.drawText(label, Qt::AlignCenter, name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DIAGRAMGESTUREOVERLAY_H
|
||||
#define DIAGRAMGESTUREOVERLAY_H
|
||||
|
||||
#include <QList>
|
||||
#include <QWidget>
|
||||
|
||||
class QAction;
|
||||
|
||||
/**
|
||||
@brief The ring shown while right-dragging on a folio: up to eight
|
||||
commands around the point where the button went down, the one in the
|
||||
mouse's direction highlighted. Releasing runs it, releasing near the
|
||||
centre cancels -- SolidWorks' mouse gestures.
|
||||
|
||||
Commands are placed clockwise from the top. The ring only draws; the
|
||||
view tracks the mouse and asks sectorAt() on release.
|
||||
*/
|
||||
class DiagramGestureOverlay : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DiagramGestureOverlay(QWidget *viewport);
|
||||
|
||||
void showAt(const QPoint ¢er, const QList<QAction *> &actions);
|
||||
void setPointer(const QPoint &viewport_pos);
|
||||
int sectorAt(const QPoint &viewport_pos) const;
|
||||
QAction *actionAt(const QPoint &viewport_pos) const;
|
||||
|
||||
static bool isEnabled();
|
||||
|
||||
static const int sectors = 8;
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
|
||||
private:
|
||||
QPoint m_center;
|
||||
QList<QAction *> m_actions;
|
||||
int m_active = -1;
|
||||
};
|
||||
|
||||
#endif // DIAGRAMGESTUREOVERLAY_H
|
||||
@@ -29,7 +29,11 @@
|
||||
DiagramPosition::DiagramPosition(const QString &letter, unsigned int number) {
|
||||
// purifie les lettres
|
||||
letter_ = letter.toUpper();
|
||||
letter_.remove(QRegularExpression("[^A-Z]"));
|
||||
//Compiled once: building it here cost more than the rest of the
|
||||
//position conversion together, once per element and per drawing item
|
||||
//on every rebuild of the project database.
|
||||
static const QRegularExpression not_a_letter(QStringLiteral("[^A-Z]"));
|
||||
letter_.remove(not_a_letter);
|
||||
number_ = number;
|
||||
}
|
||||
|
||||
|
||||
+564
-5
@@ -16,6 +16,7 @@
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagramview.h"
|
||||
#include "cellruler.h"
|
||||
#include "lastusedstyle.h"
|
||||
#include "qetproject.h"
|
||||
#include "QPropertyUndoCommand/qpropertyundocommand.h"
|
||||
@@ -29,6 +30,7 @@
|
||||
#include "qetgraphicsitem/conductortextitem.h"
|
||||
#include "qetgraphicsitem/independenttextitem.h"
|
||||
#include "qeticons.h"
|
||||
#include "qetpalette.h"
|
||||
#include "titleblock/integrationmovetemplateshandler.h"
|
||||
#include "ui/diagrampropertiesdialog.h"
|
||||
#include "ui/multipastedialog.h"
|
||||
@@ -36,6 +38,10 @@
|
||||
#include "utils/conductorcreator.h"
|
||||
#include "undocommand/addgraphicsobjectcommand.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramcontexttoolbar.h"
|
||||
#include "diagramgestureoverlay.h"
|
||||
#include "shortcutbarsettings.h"
|
||||
#include "shortcutmanager.h"
|
||||
#include "ElementsCollection/xmlelementcollection.h"
|
||||
#include "NameList/nameslist.h"
|
||||
#include "elementdialog.h"
|
||||
@@ -43,6 +49,8 @@
|
||||
#include <QDropEvent>
|
||||
#include <QPainter>
|
||||
#include <QPointer>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
|
||||
/**
|
||||
Constructeur
|
||||
@@ -95,6 +103,9 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
|
||||
m_create_template = new QAction(tr("Créer un template", "context menu action"), this);
|
||||
connect(m_create_template, &QAction::triggered, this, &DiagramView::createTemplateFromSelection);
|
||||
|
||||
//Filled each time the context menu opens, see updateFolioReportMenu()
|
||||
m_folio_report_menu = new QMenu(tr("Renvoi de folio"), this);
|
||||
|
||||
//setup three separators, to be use in context menu
|
||||
for(int i=0 ; i<3 ; ++i)
|
||||
{
|
||||
@@ -102,11 +113,27 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
|
||||
m_separators.last()->setSeparator(true);
|
||||
}
|
||||
|
||||
m_context_toolbar = new DiagramContextToolbar(viewport());
|
||||
m_gesture_overlay = new DiagramGestureOverlay(viewport());
|
||||
connect(m_diagram, &QGraphicsScene::selectionChanged, this, [this]() {
|
||||
if (m_diagram->selectedItems().isEmpty()) {
|
||||
m_context_toolbar->hide();
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_diagram, &Diagram::showDiagram, this, &DiagramView::showDiagram);
|
||||
connect(m_diagram, &QGraphicsScene::sceneRectChanged, this, &DiagramView::adjustSceneRect);
|
||||
connect(&(m_diagram -> border_and_titleblock), &BorderTitleBlock::informationChanged, this, &DiagramView::updateWindowTitle);
|
||||
connect(diagram, &Diagram::findElementRequired, this, &DiagramView::findElementRequired);
|
||||
|
||||
m_top_ruler = new CellRuler(Qt::Horizontal, this);
|
||||
m_side_ruler = new CellRuler(Qt::Vertical, this);
|
||||
m_cell_rulers_shown = QSettings().value("diagrameditor/cell_rulers", false).toBool();
|
||||
m_cell_lines_shown = QSettings().value("diagrameditor/cell_lines", false).toBool();
|
||||
connect(&m_diagram->border_and_titleblock, &BorderTitleBlock::borderChanged, this, &DiagramView::updateCellRulers);
|
||||
connect(&m_diagram->border_and_titleblock, &BorderTitleBlock::displayChanged, this, &DiagramView::updateCellRulers);
|
||||
updateCellRulers();
|
||||
|
||||
QShortcut *edit_conductor_color_shortcut = new QShortcut(QKeySequence(Qt::Key_F2), this);
|
||||
connect(edit_conductor_color_shortcut, &QShortcut::activated, [this]()
|
||||
{
|
||||
@@ -223,14 +250,72 @@ void DiagramView::handleElementDrop(QDropEvent *event)
|
||||
QPointF drop_pos;
|
||||
drop_pos = mapToScene(event->position().toPoint());
|
||||
|
||||
startElementPlacement(location, drop_pos);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::startElementPlacement
|
||||
Enter the interactive placement mode for @a location, with the pending
|
||||
element starting at @a scene_pos.
|
||||
|
||||
This is the mode where the element follows the cursor on the grid, a left
|
||||
click drops a copy, Space rotates it and the element stays loaded so a run
|
||||
of identical symbols can be placed with successive clicks.
|
||||
|
||||
Split out of handleElementDrop() so that placement is reachable without a
|
||||
drag: the mode itself was always general, it simply had no caller other
|
||||
than the end of a drop.
|
||||
@param location : the element or macro to place
|
||||
@param scene_pos : where the pending element first appears, in scene
|
||||
coordinates
|
||||
@return true if the placement mode was entered
|
||||
*/
|
||||
bool DiagramView::startElementPlacement(const ElementsLocation &location,
|
||||
const QPointF &scene_pos)
|
||||
{
|
||||
if (!diagram() || !(location.isElement() && location.exist())) {
|
||||
return false;
|
||||
}
|
||||
if (diagram()->isReadOnly()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (location.path().endsWith(".qetmak")) {
|
||||
diagram()->setEventInterface(new DiagramEventAddMacro(location, diagram(), drop_pos));
|
||||
diagram()->setEventInterface(
|
||||
new DiagramEventAddMacro(location, diagram(), scene_pos));
|
||||
} else {
|
||||
diagram()->setEventInterface(new DiagramEventAddElement(location, diagram(), drop_pos));
|
||||
//DiagramEventAddElement takes a non-const reference, so it needs
|
||||
//an lvalue it may modify. Copying keeps the caller's location
|
||||
//untouched -- QETDiagramEditor stores the same one for
|
||||
//"insert last element".
|
||||
ElementsLocation loc(location);
|
||||
diagram()->setEventInterface(
|
||||
new DiagramEventAddElement(loc, diagram(), scene_pos));
|
||||
emit elementPlacementStarted(location);
|
||||
}
|
||||
|
||||
//Set focus to the view to get event
|
||||
this->setFocus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::defaultPlacementPos
|
||||
@return where a pending element should appear when placement was not
|
||||
started by a drop, so there is no cursor position to use.
|
||||
|
||||
The cursor is used when it is over the view -- picking up a placement where
|
||||
the user is already looking -- and the centre of the visible area
|
||||
otherwise.
|
||||
*/
|
||||
QPointF DiagramView::defaultPlacementPos() const
|
||||
{
|
||||
const QPoint local = mapFromGlobal(QCursor::pos());
|
||||
if (viewport() && viewport()->rect().contains(local)) {
|
||||
return mapToScene(local);
|
||||
}
|
||||
return mapToScene(viewport() ? viewport()->rect().center()
|
||||
: rect().center());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,6 +390,7 @@ void DiagramView::handleTextDrop(QDropEvent *e) {
|
||||
*/
|
||||
void DiagramView::setVisualisationMode()
|
||||
{
|
||||
m_ctrl_shift_panning = false;
|
||||
setDragMode(ScrollHandDrag);
|
||||
applyReadOnly();
|
||||
setInteractive(false);
|
||||
@@ -316,6 +402,7 @@ void DiagramView::setVisualisationMode()
|
||||
*/
|
||||
void DiagramView::setSelectionMode()
|
||||
{
|
||||
m_ctrl_shift_panning = false;
|
||||
setDragMode(RubberBandDrag);
|
||||
setInteractive(true);
|
||||
applyReadOnly();
|
||||
@@ -385,6 +472,23 @@ void DiagramView::zoomReset()
|
||||
adjustGridToZoom();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::zoomToRect
|
||||
Adjust zoom to fit \a rect, in scene coordinate, in the view.
|
||||
@param rect
|
||||
*/
|
||||
void DiagramView::zoomToRect(const QRectF &rect)
|
||||
{
|
||||
fitInView(rect, Qt::KeepAspectRatio);
|
||||
//Zooming in makes the scroll bars appear, which resizes the viewport
|
||||
//from a queued call; that resize is anchored under the mouse and
|
||||
//would scroll away from rect, so center again once it has run.
|
||||
QMetaObject::invokeMethod(this, [this, rect]() {
|
||||
centerOn(rect.center());
|
||||
}, Qt::QueuedConnection);
|
||||
adjustGridToZoom();
|
||||
}
|
||||
|
||||
/**
|
||||
Copie les elements selectionnes du schema dans le presse-papier puis les supprime
|
||||
Copies the selected elements from the diagram to the clipboard and then deletes them
|
||||
@@ -529,6 +633,12 @@ void DiagramView::mousePressEvent(QMouseEvent *e)
|
||||
{
|
||||
e->ignore();
|
||||
|
||||
//Ctrl+Shift panning whose key release was missed (see
|
||||
//focusOutEvent()): a click without them ends it
|
||||
if (m_ctrl_shift_panning && !isCtrlShifting(e)) {
|
||||
setSelectionMode();
|
||||
}
|
||||
|
||||
if (m_fresh_focus_in)
|
||||
{
|
||||
switchToVisualisationModeIfNeeded(e);
|
||||
@@ -537,6 +647,50 @@ void DiagramView::mousePressEvent(QMouseEvent *e)
|
||||
|
||||
if (m_event_interface && m_event_interface->mousePressEvent(e)) return;
|
||||
|
||||
if (e->button() == Qt::LeftButton) {
|
||||
m_press_pos = e->position().toPoint();
|
||||
}
|
||||
|
||||
//Right button: a click opens the context menu on release, a drag is
|
||||
//a gesture (DiagramGestureOverlay). Left alone while a text is edited.
|
||||
m_swallow_native_menu = false;
|
||||
m_gesture_over_tool = false;
|
||||
if (e->button() == Qt::RightButton
|
||||
&& DiagramGestureOverlay::isEnabled()
|
||||
&& !m_diagram->focusItem())
|
||||
{
|
||||
m_gesture_tracking = true;
|
||||
m_gesture_origin = e->position().toPoint();
|
||||
m_context_toolbar->hide();
|
||||
|
||||
//A tool is running, often one a gesture just started. A right
|
||||
//click still goes to it -- it cancels or finishes the tool -- so
|
||||
//the press carries on to the scene. A drag ends the tool and
|
||||
//shows the ring (see mouseMoveEvent).
|
||||
if (m_diagram->eventInterfaceIsRunning()) {
|
||||
m_gesture_over_tool = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_swallow_native_menu = true;
|
||||
|
||||
//Select what is under the mouse, as the context menu does, so
|
||||
//a gesture acts on it
|
||||
if (QGraphicsItem *item = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) {
|
||||
if (!item->isSelected()) {
|
||||
m_diagram->clearSelection();
|
||||
//Clearing the selection can delete handler items, so
|
||||
//look the item up again (see contextMenuEvent)
|
||||
if (QGraphicsItem *again = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) {
|
||||
again->setSelected(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//Start drag view when hold the middle button
|
||||
if (e->button() == Qt::MiddleButton)
|
||||
{
|
||||
@@ -584,9 +738,35 @@ void DiagramView::mousePressEvent(QMouseEvent *e)
|
||||
*/
|
||||
void DiagramView::mouseMoveEvent(QMouseEvent *e)
|
||||
{
|
||||
m_last_mouse_pos = e->pos();
|
||||
setToolTip(tr("X: %1 Y: %2").arg(e->pos().x()).arg(e->pos().y()));
|
||||
if (m_event_interface && m_event_interface->mouseMoveEvent(e)) return;
|
||||
|
||||
if (m_gesture_tracking)
|
||||
{
|
||||
const QPoint pos = e->position().toPoint();
|
||||
if (!m_gesture_overlay->isVisible()
|
||||
&& (pos - m_gesture_origin).manhattanLength() > 2 * QApplication::startDragDistance()) {
|
||||
if (m_gesture_over_tool) {
|
||||
//The drag is a gesture: end the tool, and ignore the
|
||||
//platform's right-click menu like any other gesture
|
||||
m_diagram->clearEventInterface();
|
||||
m_swallow_native_menu = true;
|
||||
}
|
||||
m_gesture_overlay->showAt(m_gesture_origin, selectionCommands());
|
||||
}
|
||||
if (m_gesture_overlay->isVisible()) {
|
||||
m_gesture_overlay->setPointer(pos);
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
//Not a drag yet: a running tool keeps following the mouse
|
||||
if (!m_gesture_over_tool) {
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag the view
|
||||
if (e->buttons() == Qt::MiddleButton)
|
||||
{
|
||||
@@ -648,6 +828,40 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e)
|
||||
{
|
||||
if (m_event_interface && m_event_interface->mouseReleaseEvent(e)) return;
|
||||
|
||||
//A plain right click on a running tool falls through: the tool
|
||||
//handles it, as it always has
|
||||
if (m_gesture_tracking && e->button() == Qt::RightButton
|
||||
&& !(m_gesture_over_tool && !m_gesture_overlay->isVisible()))
|
||||
{
|
||||
m_gesture_tracking = false;
|
||||
const QPoint pos = e->position().toPoint();
|
||||
if (m_gesture_overlay->isVisible())
|
||||
{
|
||||
//A gesture: run the command it points at, if any
|
||||
QAction *action = m_gesture_overlay->actionAt(pos);
|
||||
m_gesture_overlay->hide();
|
||||
if (action) {
|
||||
action->trigger();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//A plain right click: the context menu, opened here on
|
||||
//release on every platform
|
||||
QContextMenuEvent menu_event(QContextMenuEvent::Mouse, pos,
|
||||
e->globalPosition().toPoint(),
|
||||
e->modifiers());
|
||||
m_menu_from_gesture = true;
|
||||
contextMenuEvent(&menu_event);
|
||||
m_menu_from_gesture = false;
|
||||
}
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
if (e->button() == Qt::RightButton) {
|
||||
m_gesture_tracking = false;
|
||||
}
|
||||
|
||||
// Stop drag view
|
||||
if (e->button() == Qt::MiddleButton)
|
||||
{
|
||||
@@ -692,7 +906,60 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e)
|
||||
e->accept();
|
||||
}
|
||||
else
|
||||
{
|
||||
QGraphicsView::mouseReleaseEvent(e);
|
||||
|
||||
//A click, not a drag: moving items or a rubber band selection
|
||||
//should not be followed by a toolbar under the mouse.
|
||||
const QPoint pos = e->position().toPoint();
|
||||
if (e->button() == Qt::LeftButton
|
||||
&& (pos - m_press_pos).manhattanLength() < QApplication::startDragDistance()) {
|
||||
showContextToolbar(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::selectionCommands
|
||||
@return the shortcut bar's commands for the current selection, as this
|
||||
window's actions
|
||||
*/
|
||||
QList<QAction *> DiagramView::selectionCommands() const
|
||||
{
|
||||
QList<QAction *> actions;
|
||||
QETDiagramEditor *qde = diagramEditor();
|
||||
if (!qde) {
|
||||
return actions;
|
||||
}
|
||||
const auto context = ShortcutBarSettings::contextFor(m_diagram->selectedItems());
|
||||
for (const QString &id : ShortcutBarSettings::ids(context)) {
|
||||
if (QAction *action = ShortcutManager::instance().action(id, qde)) {
|
||||
actions << action;
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::showContextToolbar
|
||||
After a click that leaves something selected, show the shortcut bar's
|
||||
commands for that selection beside the cursor. Not while placing or
|
||||
drawing, nor on a read-only folio, nor when switched off in the
|
||||
configuration.
|
||||
@param viewport_pos : where the click was
|
||||
*/
|
||||
void DiagramView::showContextToolbar(const QPoint &viewport_pos)
|
||||
{
|
||||
const QList<QGraphicsItem *> selection = m_diagram->selectedItems();
|
||||
QETDiagramEditor *qde = diagramEditor();
|
||||
if (selection.isEmpty() || !qde
|
||||
|| m_diagram->isReadOnly() || m_diagram->eventInterfaceIsRunning()
|
||||
|| !DiagramContextToolbar::isEnabled()) {
|
||||
m_context_toolbar->hide();
|
||||
return;
|
||||
}
|
||||
|
||||
m_context_toolbar->showAt(viewport_pos, selectionCommands());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -772,6 +1039,23 @@ void DiagramView::focusInEvent(QFocusEvent *e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::focusOutEvent
|
||||
Leave the Ctrl+Shift panning: without the focus, the view will not see
|
||||
Ctrl or Shift being released, which is what normally ends it. A
|
||||
Ctrl+Shift shortcut that opens a window (Ctrl+Shift+M, the command
|
||||
search) left the view panning, ignoring clicks on the folio, on Windows,
|
||||
where no other key release reached the view to end it.
|
||||
@param e
|
||||
*/
|
||||
void DiagramView::focusOutEvent(QFocusEvent *e)
|
||||
{
|
||||
if (m_ctrl_shift_panning) {
|
||||
setSelectionMode();
|
||||
}
|
||||
PaletteGraphicsView::focusOutEvent(e);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::focusNextPrevChild
|
||||
By default, QWidget intercepts Tab/Shift+Tab to move keyboard focus to
|
||||
@@ -825,6 +1109,18 @@ void DiagramView::keyPressEvent(QKeyEvent *e)
|
||||
focusNextChild();
|
||||
}
|
||||
return;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
//Repeat the last drawing or placing command, as SolidWorks
|
||||
//does. Not while a tool is running or a text has the focus:
|
||||
//both use Enter themselves.
|
||||
if (e->modifiers() == Qt::NoModifier
|
||||
&& !m_diagram->eventInterfaceIsRunning()
|
||||
&& !m_diagram->focusItem()
|
||||
&& diagramEditor()->repeatLastCommand()) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case Qt::Key_PageUp:
|
||||
current_project->changeTabUp();
|
||||
return;
|
||||
@@ -1169,6 +1465,158 @@ void DiagramView::paintingInverted(bool inverted)
|
||||
m_diagram->setInvertedLightness(inverted);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::setCellLinesShown
|
||||
Show or hide the lines that mark the columns and the rows of the folio
|
||||
border across the drawing, in this view only: printing and exporting
|
||||
never draw them.
|
||||
@param shown
|
||||
*/
|
||||
void DiagramView::setCellLinesShown(bool shown)
|
||||
{
|
||||
m_cell_lines_shown = shown;
|
||||
viewport()->update();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::drawBackground
|
||||
Reimplemented from PaletteGraphicsView: over the folio background, the
|
||||
cell lines when they are shown. Dashed and faint, so they do not read
|
||||
as conductors, and under every item.
|
||||
@param painter
|
||||
@param rect
|
||||
*/
|
||||
void DiagramView::drawBackground(QPainter *painter, const QRectF &rect)
|
||||
{
|
||||
PaletteGraphicsView::drawBackground(painter, rect);
|
||||
|
||||
const BorderTitleBlock &border = m_diagram->border_and_titleblock;
|
||||
if (!m_cell_lines_shown || !border.borderIsDisplayed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Where the border draws its cells, whether or not the other
|
||||
//header is displayed
|
||||
const QPointF origin(Diagram::margin + border.rowsHeaderWidth(),
|
||||
Diagram::margin + border.columnsHeaderHeight());
|
||||
const qreal right = origin.x() + border.columnsCount() * border.columnsWidth();
|
||||
const qreal bottom = origin.y() + border.rowsCount() * border.rowsHeight();
|
||||
|
||||
QPainter *p = scenePainter(painter);
|
||||
p->save();
|
||||
p->setRenderHint(QPainter::Antialiasing, false);
|
||||
QColor color = QET::Palette::gridDotColor(Diagram::background_color,
|
||||
invertsLightness());
|
||||
color.setAlpha(70);
|
||||
QPen pen(color, 1, Qt::DashLine);
|
||||
pen.setCosmetic(true);
|
||||
p->setPen(pen);
|
||||
|
||||
if (border.columnsAreDisplayed()) {
|
||||
for (int i = 1 ; i < border.columnsCount() ; ++i) {
|
||||
const qreal x = origin.x() + i * border.columnsWidth();
|
||||
if (x >= rect.left() && x <= rect.right()) {
|
||||
p->drawLine(QPointF(x, origin.y()), QPointF(x, bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (border.rowsAreDisplayed()) {
|
||||
for (int i = 1 ; i < border.rowsCount() ; ++i) {
|
||||
const qreal y = origin.y() + i * border.rowsHeight();
|
||||
if (y >= rect.top() && y <= rect.bottom()) {
|
||||
p->drawLine(QPointF(origin.x(), y), QPointF(right, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
p->restore();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::setCellRulersShown
|
||||
Show or hide the rulers that keep the column numbers and the row
|
||||
letters of the folio border in sight along the edges of this view.
|
||||
@param shown
|
||||
*/
|
||||
void DiagramView::setCellRulersShown(bool shown)
|
||||
{
|
||||
m_cell_rulers_shown = shown;
|
||||
updateCellRulers();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::updateCellRulers
|
||||
Show each ruler when the rulers are wanted, the folio shows the
|
||||
matching header and that header is not already wholly in sight, and
|
||||
give it room in the margins of the view. The drawing does not move on
|
||||
screen when a ruler comes or goes: the ruler covers or uncovers the
|
||||
edge of the viewport, as if it lay over it.
|
||||
*/
|
||||
void DiagramView::updateCellRulers()
|
||||
{
|
||||
const BorderTitleBlock &border = m_diagram->border_and_titleblock;
|
||||
const QRectF in_sight = mapToScene(viewport()->rect()).boundingRect();
|
||||
const QRectF columns = border.columnsRect();
|
||||
const QRectF rows = border.rowsRect();
|
||||
const bool top = m_cell_rulers_shown
|
||||
&& border.borderIsDisplayed() && border.columnsAreDisplayed()
|
||||
&& (columns.top() < in_sight.top() || columns.bottom() > in_sight.bottom());
|
||||
const bool side = m_cell_rulers_shown
|
||||
&& border.borderIsDisplayed() && border.rowsAreDisplayed()
|
||||
&& (rows.left() < in_sight.left() || rows.right() > in_sight.right());
|
||||
const int thickness = m_top_ruler->thickness();
|
||||
|
||||
m_top_ruler->setVisible(top);
|
||||
m_side_ruler->setVisible(side);
|
||||
m_side_ruler->setLeadingSpace(top ? thickness : 0);
|
||||
|
||||
const QMargins margins(side ? thickness : 0, top ? thickness : 0, 0, 0);
|
||||
if (margins != viewportMargins()) {
|
||||
const QPointF origin = mapToScene(viewport()->rect().center());
|
||||
const QPoint before = viewport()->mapToGlobal(mapFromScene(origin));
|
||||
setViewportMargins(margins);
|
||||
const QPoint moved = viewport()->mapToGlobal(mapFromScene(origin)) - before;
|
||||
horizontalScrollBar()->setValue(horizontalScrollBar()->value() + moved.x());
|
||||
verticalScrollBar()->setValue(verticalScrollBar()->value() + moved.y());
|
||||
}
|
||||
placeCellRulers();
|
||||
m_top_ruler->update();
|
||||
m_side_ruler->update();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::placeCellRulers
|
||||
Lay the rulers along the top and the left edges of the viewport, the
|
||||
side ruler covering the corner too when both are shown.
|
||||
*/
|
||||
void DiagramView::placeCellRulers()
|
||||
{
|
||||
if (!m_top_ruler) {
|
||||
return;
|
||||
}
|
||||
const QRect viewport_rect = viewport()->geometry();
|
||||
const int thickness = m_top_ruler->thickness();
|
||||
const int corner = m_top_ruler->isHidden() ? 0 : thickness;
|
||||
m_top_ruler->setGeometry(viewport_rect.left(), viewport_rect.top() - thickness,
|
||||
viewport_rect.width(), thickness);
|
||||
m_side_ruler->setGeometry(viewport_rect.left() - thickness, viewport_rect.top() - corner,
|
||||
thickness, viewport_rect.height() + corner);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::viewportEvent
|
||||
Keep the rulers along the viewport when it resizes, which it also does
|
||||
without the view resizing, when the scroll bars come and go.
|
||||
@param event
|
||||
@return what QGraphicsView::viewportEvent() returns
|
||||
*/
|
||||
bool DiagramView::viewportEvent(QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::Resize) {
|
||||
placeCellRulers();
|
||||
}
|
||||
return PaletteGraphicsView::viewportEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::paintEvent
|
||||
Reimplemented from QGraphicsView
|
||||
@@ -1178,6 +1626,19 @@ void DiagramView::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
PaletteGraphicsView::paintEvent(event);
|
||||
|
||||
//Scrolling and zooming both repaint the viewport: follow them.
|
||||
//Showing or hiding a ruler resizes the viewport, which cannot be
|
||||
//done while it paints.
|
||||
if (viewportTransform() != m_rulers_transform) {
|
||||
m_rulers_transform = viewportTransform();
|
||||
m_top_ruler->update();
|
||||
m_side_ruler->update();
|
||||
if (m_cell_rulers_shown) {
|
||||
QMetaObject::invokeMethod(this, &DiagramView::updateCellRulers,
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_free_rubberbanding && m_free_rubberband.count() >= 3)
|
||||
{
|
||||
QPainter painter(viewport());
|
||||
@@ -1202,6 +1663,7 @@ bool DiagramView::switchToVisualisationModeIfNeeded(QInputEvent *e) {
|
||||
if (isCtrlShifting(e) && !selectedItemHasFocus()) {
|
||||
if (dragMode() != QGraphicsView::ScrollHandDrag) {
|
||||
setVisualisationMode();
|
||||
m_ctrl_shift_panning = true;
|
||||
return(true);
|
||||
}
|
||||
}
|
||||
@@ -1298,10 +1760,17 @@ QList<QAction *> DiagramView::contextMenuActions() const
|
||||
{
|
||||
if (m_diagram->selectedItems().isEmpty())
|
||||
{
|
||||
//Drawing comes first. The row and column actions change
|
||||
//the folio's layout and are rarely wanted, so they sit one
|
||||
//level down where a stray click cannot reach them.
|
||||
list << m_paste_here;
|
||||
list << m_separators.at(0);
|
||||
list << qde->m_insert_last_element;
|
||||
list << m_folio_report_menu->menuAction();
|
||||
list << qde->m_add_item_menu->menuAction();
|
||||
list << m_separators.at(1);
|
||||
list << qde->m_edit_diagram_properties;
|
||||
list << qde->m_row_column_actions_group.actions();
|
||||
list << qde->m_row_column_menu->menuAction();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1313,15 +1782,24 @@ QList<QAction *> DiagramView::contextMenuActions() const
|
||||
list << qde->m_conductor_reset;
|
||||
list << m_separators.at(1);
|
||||
list << qde->m_selection_actions_group.actions();
|
||||
list << qde->m_align_menu->menuAction();
|
||||
list << m_separators.at(2);
|
||||
list << qde->m_depth_action_group->actions();
|
||||
}
|
||||
|
||||
//Remove from the context menu the actions which are disabled.
|
||||
//Remove from the context menu the actions which are disabled,
|
||||
//and the submenus in which every action is disabled.
|
||||
const QList<QAction *> actions = list;
|
||||
for(QAction *action : actions)
|
||||
{
|
||||
if (!action->isEnabled()) {
|
||||
bool usable = action->isEnabled();
|
||||
if (usable && action->menu())
|
||||
{
|
||||
const QList<QAction *> sub_actions = action->menu()->actions();
|
||||
usable = std::any_of(sub_actions.cbegin(), sub_actions.cend(),
|
||||
[](QAction *a) { return a->isEnabled(); });
|
||||
}
|
||||
if (!usable) {
|
||||
list.removeAll(action);
|
||||
}
|
||||
}
|
||||
@@ -1353,6 +1831,14 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
|
||||
//right-click gets.
|
||||
const bool from_keyboard = e->reason() == QContextMenuEvent::Keyboard;
|
||||
|
||||
//With gestures on, a right press is tracked by mousePressEvent and
|
||||
//the menu opened on release; the platform's own event (sent on press
|
||||
//on X11, on release on Windows) would open it a second time.
|
||||
if (!from_keyboard && m_swallow_native_menu && !m_menu_from_gesture) {
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (from_keyboard)
|
||||
{
|
||||
//Aim at the selection when there is one, so the menu appears
|
||||
@@ -1401,6 +1887,7 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
|
||||
{
|
||||
m_paste_here_pos = menu_pos;
|
||||
m_paste_here->setEnabled(Diagram::clipboardMayContainDiagram());
|
||||
updateFolioReportMenu();
|
||||
}
|
||||
|
||||
QList <QAction *> list = contextMenuActions();
|
||||
@@ -1413,6 +1900,78 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::updateFolioReportMenu
|
||||
Fill the "Renvoi de folio" submenu of the context menu with the folio
|
||||
report elements this project already uses -- the ones in its embedded
|
||||
collection -- or, when it has none yet, the coming and going arrows of
|
||||
the common collection. An entry places its element where the context
|
||||
menu was opened. Left empty, and so hidden, on a read-only diagram.
|
||||
*/
|
||||
void DiagramView::updateFolioReportMenu()
|
||||
{
|
||||
m_folio_report_menu->clear();
|
||||
if (m_diagram->isReadOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QList<ElementsLocation> locations;
|
||||
QETProject *project = m_diagram->project();
|
||||
XmlElementCollection *collection =
|
||||
project ? project->embeddedElementCollection() : nullptr;
|
||||
if (collection)
|
||||
{
|
||||
QSet<QString> names;
|
||||
const QDomNodeList definitions =
|
||||
collection->root().elementsByTagName(QStringLiteral("definition"));
|
||||
for (int i = 0 ; i < definitions.count() ; ++i)
|
||||
{
|
||||
const QDomElement definition = definitions.at(i).toElement();
|
||||
const QString link_type = definition.attribute(QStringLiteral("link_type"));
|
||||
if (link_type != QLatin1String("next_report")
|
||||
&& link_type != QLatin1String("previous_report")) {
|
||||
continue;
|
||||
}
|
||||
const ElementsLocation location =
|
||||
collection->domToLocation(definition.parentNode().toElement());
|
||||
//A project keeps dated copies of the same element
|
||||
//(01previous_folio-20140521204742.elmt), so list each name once.
|
||||
if (!location.exist() || names.contains(location.name())) {
|
||||
continue;
|
||||
}
|
||||
names.insert(location.name());
|
||||
locations << location;
|
||||
}
|
||||
}
|
||||
|
||||
if (locations.isEmpty())
|
||||
{
|
||||
for (const auto path : {
|
||||
"common://10_electric/10_allpole/100_folio_referencing/01coming_arrow.elmt",
|
||||
"common://10_electric/10_allpole/100_folio_referencing/02going_arrow.elmt"})
|
||||
{
|
||||
const ElementsLocation location(QString::fromLatin1(path));
|
||||
if (location.exist()) {
|
||||
locations << location;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(locations.begin(), locations.end(),
|
||||
[](const ElementsLocation &a, const ElementsLocation &b) {
|
||||
return a.name().localeAwareCompare(b.name()) < 0;
|
||||
});
|
||||
|
||||
for (const ElementsLocation &location : std::as_const(locations))
|
||||
{
|
||||
QAction *action = m_folio_report_menu->addAction(location.icon(),
|
||||
location.name());
|
||||
connect(action, &QAction::triggered, this, [this, location]() {
|
||||
startElementPlacement(location, mapToScene(m_paste_here_pos));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief DiagramView::createTemplateFromSelection
|
||||
* Triggered from the context menu to create a new template (macro) from the current selection.
|
||||
|
||||
+52
-1
@@ -24,12 +24,16 @@
|
||||
#include <QClipboard>
|
||||
#include "palettegraphicsview.h"
|
||||
|
||||
class CellRuler;
|
||||
class Conductor;
|
||||
class Diagram;
|
||||
class DiagramContextToolbar;
|
||||
class DiagramGestureOverlay;
|
||||
class QETDiagramEditor;
|
||||
class DVEventInterface;
|
||||
class QInputEvent;
|
||||
class QGestureEvent;
|
||||
class QMenu;
|
||||
|
||||
/**
|
||||
This class provides a widget to render an electric diagram in an editable,
|
||||
@@ -54,14 +58,37 @@ class DiagramView : public PaletteGraphicsView
|
||||
QAction *m_paste_here = nullptr;
|
||||
QAction *m_multi_paste = nullptr;
|
||||
QAction *m_create_template = nullptr;
|
||||
QMenu *m_folio_report_menu = nullptr;
|
||||
QPoint m_paste_here_pos;
|
||||
QPoint m_press_pos;
|
||||
DiagramContextToolbar *m_context_toolbar = nullptr;
|
||||
/// Right-drag gestures: tracking since the right button went down
|
||||
bool m_gesture_tracking = false;
|
||||
/// The platform's own right-click menu event is to be ignored:
|
||||
/// the view opens the menu itself on release
|
||||
bool m_swallow_native_menu = false;
|
||||
bool m_menu_from_gesture = false;
|
||||
/// The right press went to a running tool; a drag ends the tool
|
||||
bool m_gesture_over_tool = false;
|
||||
QPoint m_gesture_origin;
|
||||
DiagramGestureOverlay *m_gesture_overlay = nullptr;
|
||||
QPoint m_last_mouse_pos = QPoint(-1, -1);
|
||||
QPointF m_drag_last_pos;
|
||||
bool m_fresh_focus_in,
|
||||
m_first_activation = true;
|
||||
/// True while the view pans because Ctrl+Shift is held, as opposed
|
||||
/// to the visualisation mode chosen from the toolbar.
|
||||
bool m_ctrl_shift_panning = false;
|
||||
QList<QAction *> m_separators;
|
||||
QPolygonF m_free_rubberband;
|
||||
bool m_free_rubberbanding = false;
|
||||
|
||||
CellRuler *m_top_ruler = nullptr;
|
||||
CellRuler *m_side_ruler = nullptr;
|
||||
bool m_cell_rulers_shown = false;
|
||||
/// Last viewport transform the rulers were painted for
|
||||
QTransform m_rulers_transform;
|
||||
bool m_cell_lines_shown = false;
|
||||
|
||||
|
||||
public:
|
||||
QString title() const;
|
||||
@@ -71,12 +98,25 @@ class DiagramView : public PaletteGraphicsView
|
||||
void editSelection();
|
||||
void setEventInterface (DVEventInterface *event_interface);
|
||||
QList<QAction *> contextMenuActions() const;
|
||||
/// Last mouse position seen by mouseMoveEvent(), in viewport
|
||||
/// coordinates -- (-1, -1) if the mouse hasn't moved over this
|
||||
/// view yet. Filled from ordinary Qt mouse events, not a global
|
||||
/// cursor query (QCursor::pos()/setPos() are silently ignored by
|
||||
/// several window managers and compositors, Wayland included).
|
||||
QPoint lastMousePos() const { return m_last_mouse_pos; }
|
||||
|
||||
bool startElementPlacement(const ElementsLocation &location,
|
||||
const QPointF &scene_pos);
|
||||
QPointF defaultPlacementPos() const;
|
||||
void setCellRulersShown(bool shown);
|
||||
void setCellLinesShown(bool shown);
|
||||
|
||||
protected:
|
||||
void mouseDoubleClickEvent(QMouseEvent *) override;
|
||||
void contextMenuEvent(QContextMenuEvent *) override;
|
||||
void wheelEvent(QWheelEvent *) override;
|
||||
void focusInEvent(QFocusEvent *) override;
|
||||
void focusOutEvent(QFocusEvent *) override;
|
||||
void keyPressEvent(QKeyEvent *) override;
|
||||
void keyReleaseEvent(QKeyEvent *) override;
|
||||
bool event(QEvent *) override;
|
||||
@@ -84,6 +124,8 @@ class DiagramView : public PaletteGraphicsView
|
||||
///Set for one call only, by the Escape handler, to let focus leave the view.
|
||||
bool m_releasing_focus = false;
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
bool viewportEvent(QEvent *event) override;
|
||||
void drawBackground(QPainter *painter, const QRectF &rect) override;
|
||||
void paintingInverted(bool inverted) override;
|
||||
void mousePressEvent(QMouseEvent *) override;
|
||||
void mouseMoveEvent(QMouseEvent *) override;
|
||||
@@ -99,6 +141,7 @@ class DiagramView : public PaletteGraphicsView
|
||||
|
||||
private:
|
||||
void handleElementDrop(QDropEvent *);
|
||||
void updateFolioReportMenu();
|
||||
void handleTitleBlockDrop(QDropEvent *);
|
||||
void handleTextDrop(QDropEvent *);
|
||||
void scrollOnMovement(QKeyEvent *);
|
||||
@@ -106,6 +149,10 @@ class DiagramView : public PaletteGraphicsView
|
||||
QRectF viewedSceneRect() const;
|
||||
bool mustIntegrateTitleBlockTemplate(const TitleBlockTemplateLocation &) const;
|
||||
bool gestures() const;
|
||||
void updateCellRulers();
|
||||
void placeCellRulers();
|
||||
void showContextToolbar(const QPoint &viewport_pos);
|
||||
QList<QAction *> selectionCommands() const;
|
||||
|
||||
/// Lowest and highest allowed value of the view transform scale (m11).
|
||||
/// Prevents wheel-zoom from driving the transform to overflow, which
|
||||
@@ -125,6 +172,9 @@ class DiagramView : public PaletteGraphicsView
|
||||
/// Signal emitted when free rubberband changed.
|
||||
/// When free rubberband selection ends this signal will be emitted with null value.
|
||||
void freeRubberBandChanged(QPolygonF polygon);
|
||||
/// Signal emitted when the placement mode is entered for an
|
||||
/// element (not a macro), whether from a drop or not.
|
||||
void elementPlacementStarted(const ElementsLocation &);
|
||||
|
||||
public slots:
|
||||
void setVisualisationMode();
|
||||
@@ -133,6 +183,7 @@ class DiagramView : public PaletteGraphicsView
|
||||
void zoomFit();
|
||||
void zoomContent();
|
||||
void zoomReset();
|
||||
void zoomToRect(const QRectF &rect);
|
||||
void cut();
|
||||
void copy();
|
||||
void paste(const QPointF & = QPointF(), QClipboard::Mode = QClipboard::Clipboard);
|
||||
|
||||
@@ -18,15 +18,17 @@
|
||||
#include "pastepartscommand.h"
|
||||
|
||||
#include "../elementview.h"
|
||||
#include "../graphicspart/partdynamictextfield.h"
|
||||
#include "../graphicspart/partterminal.h"
|
||||
#include "../graphicspart/parttext.h"
|
||||
|
||||
/**
|
||||
@brief PastePartsCommand::PastePartsCommand
|
||||
@param view : view where this command work
|
||||
@param content_to_paste : content to paste
|
||||
@param parent : parent undo command
|
||||
@note all terminal stored in content_to_paste get a new uuid
|
||||
in the constructor of this class to avoid have several terminal
|
||||
@note all terminal and primitives stored in content_to_paste get a new
|
||||
uuid in the constructor of this class to avoid have several parts
|
||||
of an element with the same uuid.
|
||||
*/
|
||||
PastePartsCommand::PastePartsCommand(
|
||||
@@ -40,6 +42,20 @@ PastePartsCommand::PastePartsCommand(
|
||||
if (qgi->type() == PartTerminal::Type) {
|
||||
auto part_terminal = static_cast<PartTerminal*>(qgi);
|
||||
part_terminal->setNewUuid();
|
||||
}
|
||||
//Same for the primitives, whose uuid would otherwise name both
|
||||
//the original and its copy.
|
||||
else if (qgi->type() == PartText::Type) {
|
||||
static_cast<PartText *>(qgi)->setNewUuid();
|
||||
}
|
||||
//Only the definition's own identity: a placed element gives
|
||||
//each of its dynamic texts a fresh uuid anyway (see
|
||||
//Element::parseDynamicText()).
|
||||
else if (qgi->type() == PartDynamicTextField::Type) {
|
||||
static_cast<PartDynamicTextField *>(qgi)->setNewUuid();
|
||||
}
|
||||
else if (auto graphic_part = dynamic_cast<CustomElementGraphicPart *>(qgi)) {
|
||||
graphic_part->setNewUuid();
|
||||
}
|
||||
m_pasted_content.append(qgi);
|
||||
}
|
||||
|
||||
@@ -507,6 +507,9 @@ void CustomElementGraphicPart::stylesToXml(QDomElement &qde) const
|
||||
if (color != color_map.end()) { css_like_styles += color.value(); }
|
||||
|
||||
qde.setAttribute("style", css_like_styles);
|
||||
//Every graphic part writes and reads its styles through this pair,
|
||||
//so its uuid is kept here rather than in six toXml()/fromXml().
|
||||
qde.setAttribute("uuid", m_uuid.toString());
|
||||
qde.setAttribute("antialias", _antialiased ? "true" : "false");
|
||||
}
|
||||
|
||||
@@ -520,6 +523,13 @@ void CustomElementGraphicPart::stylesFromXml(const QDomElement &qde)
|
||||
{
|
||||
resetStyles();
|
||||
|
||||
//Absent from definitions written before parts carried one: keep the
|
||||
//fresh uuid this part was built with, and the next save persists it.
|
||||
const QUuid uuid(qde.attribute(QStringLiteral("uuid")));
|
||||
if (!uuid.isNull()) {
|
||||
m_uuid = uuid;
|
||||
}
|
||||
|
||||
//Get the list of pair style/value
|
||||
QStringList styles = qde.attribute("style").split(";", Qt::SkipEmptyParts);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#define CUSTOM_ELEMENT_GRAPHIC_PART_H
|
||||
|
||||
#include <QGraphicsObject>
|
||||
#include <QUuid>
|
||||
#include "customelementpart.h"
|
||||
|
||||
class QETElementEditor;
|
||||
@@ -305,6 +306,12 @@ class CustomElementGraphicPart : public QGraphicsObject, public CustomElementPar
|
||||
const QColor &/*color*/) {}
|
||||
virtual void resetAllHandlerColor() {}
|
||||
|
||||
//Identity of this primitive inside its element definition, for
|
||||
//tools that address one part of a drawing (a script, the MCP
|
||||
//server). Saved with the styles, see stylesToXml().
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setNewUuid() {m_uuid = QUuid::createUuid();}
|
||||
|
||||
protected:
|
||||
void stylesToXml (QDomElement &) const;
|
||||
void stylesFromXml(const QDomElement &);
|
||||
@@ -323,6 +330,7 @@ class CustomElementGraphicPart : public QGraphicsObject, public CustomElementPar
|
||||
// attributes
|
||||
bool m_hovered;
|
||||
private:
|
||||
QUuid m_uuid = QUuid::createUuid();
|
||||
LineStyle _linestyle;
|
||||
LineWeight _lineweight;
|
||||
Filling _filling ;
|
||||
|
||||
@@ -75,6 +75,8 @@ class PartDynamicTextField : public QGraphicsTextItem, public CustomElementPart
|
||||
QString xmlName() const override;
|
||||
static QString xmlTaggName() {return QString("dynamic_text");}
|
||||
bool isUseless() const override {return false;}
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setNewUuid() {m_uuid = QUuid::createUuid();}
|
||||
QRectF sceneGeometricRect() const override {return sceneBoundingRect();}
|
||||
void startUserTransformation(const QRectF &initial_selection_rect) override;
|
||||
void handleUserTransformation(const QRectF &initial_selection_rect, const QRectF &new_selection_rect) override;
|
||||
|
||||
@@ -389,6 +389,11 @@ void PartTerminal::setNewUuid()
|
||||
d -> m_uuid = QUuid::createUuid();
|
||||
}
|
||||
|
||||
QUuid PartTerminal::uuid() const
|
||||
{
|
||||
return d -> m_uuid;
|
||||
}
|
||||
|
||||
void PartTerminal::setShowName(bool show)
|
||||
{
|
||||
if (d->m_show_name == show) return;
|
||||
|
||||
@@ -141,6 +141,9 @@ class PartTerminal : public CustomElementGraphicPart
|
||||
void setMasterLabelIndex(int index);
|
||||
|
||||
void setNewUuid();
|
||||
//The terminal's own uuid, the one saved on <terminal>: hides
|
||||
//CustomElementGraphicPart::uuid(), which a terminal never saves.
|
||||
QUuid uuid() const;
|
||||
|
||||
QRectF labelRect() const;
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@ void PartText::flip(qreal axis_y) {
|
||||
void PartText::fromXml(const QDomElement &xml_element) {
|
||||
bool ok;
|
||||
|
||||
const QUuid uuid(xml_element.attribute(QStringLiteral("uuid")));
|
||||
if (!uuid.isNull()) {
|
||||
m_uuid = uuid;
|
||||
}
|
||||
|
||||
if (xml_element.hasAttribute("size")) {
|
||||
int font_size = xml_element.attribute("size").toInt(&ok);
|
||||
if (!ok || font_size < 1) {
|
||||
@@ -169,6 +174,7 @@ const QDomElement PartText::toXml(QDomDocument &xml_document) const
|
||||
xml_element.setAttribute("font", QETUtils::fontToString(font()));
|
||||
xml_element.setAttribute("rotation", QString::number(rot));
|
||||
xml_element.setAttribute("color", defaultTextColor().name());
|
||||
xml_element.setAttribute("uuid", m_uuid.toString());
|
||||
|
||||
// Only written when different from the historical behaviour, so
|
||||
// existing .elmt files round-trip byte-identical.
|
||||
|
||||
@@ -50,6 +50,10 @@ class PartText : public QGraphicsTextItem, public CustomElementPart {
|
||||
private:
|
||||
PartText(const PartText &);
|
||||
|
||||
public:
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setNewUuid() {m_uuid = QUuid::createUuid();}
|
||||
|
||||
// methods
|
||||
public:
|
||||
enum { Type = UserType + 1107 };
|
||||
@@ -100,6 +104,7 @@ class PartText : public QGraphicsTextItem, public CustomElementPart {
|
||||
QRectF boundingRect() const override;
|
||||
|
||||
private:
|
||||
QUuid m_uuid = QUuid::createUuid();
|
||||
QPointF margin() const;
|
||||
void applyLineAlignment();
|
||||
void prepareAlignment();
|
||||
|
||||
@@ -589,6 +589,29 @@ void ElementPropertiesEditorWidget::populateSlaveGroupsTable()
|
||||
contact_ct->setValue(group.contactCount);
|
||||
ui->m_slave_groups_table->setCellWidget(i, 2, contact_ct);
|
||||
|
||||
// When the contact count changes, keep the terminal count in step
|
||||
// with it, otherwise the two drift apart (both are edited
|
||||
// independently): the stored terminals-per-contact ratio is kept,
|
||||
// or the contact type default (2, 3 for a switch) is used when the
|
||||
// stored values don't divide evenly (inconsistent legacy data).
|
||||
const int old_contacts = group.contactCount;
|
||||
const int old_terminals = group.terminalCount;
|
||||
connect(contact_ct, QOverload<int>::of(&QSpinBox::valueChanged),
|
||||
this, [this, i, old_contacts, old_terminals](int val) {
|
||||
if (i < m_data.m_slave_contact_groups.size()) {
|
||||
readSlaveGroupsFromTable();
|
||||
auto &group = m_data.m_slave_contact_groups[i];
|
||||
int per_pole = old_terminals / qMax(1, old_contacts);
|
||||
if (per_pole < 1 || old_terminals % qMax(1, old_contacts) != 0)
|
||||
per_pole = group.type == ElementData::SW ? 3 : 2;
|
||||
else if (group.type == ElementData::SW && per_pole < 3)
|
||||
per_pole = 3; //a switch needs common, NC and NO
|
||||
group.contactCount = val;
|
||||
group.terminalCount = val * per_pole;
|
||||
populateSlaveGroupsTable();
|
||||
}
|
||||
});
|
||||
|
||||
// Terminal count
|
||||
auto *terminal_ct = new QSpinBox(ui->m_slave_groups_table);
|
||||
terminal_ct->setMinimum(1);
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
#include <QMessageBox>
|
||||
#include "qetgraphicsitem/element.h"
|
||||
#include "qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "qetgraphicsitem/diagramimageitem.h"
|
||||
#include "qetgraphicsitem/independenttextitem.h"
|
||||
#include "qetgraphicsitem/qetshapeitem.h"
|
||||
#include "qetinformation.h"
|
||||
|
||||
/*
|
||||
@@ -800,6 +803,18 @@ void ElementsPanelWidget::duplicateDiagram()
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (auto text = dynamic_cast<IndependentTextItem *>(item)) {
|
||||
// Not a database key (yet), but a script or the MCP server
|
||||
// addresses a text, image or shape by it: a copy must not
|
||||
// answer to its source's name.
|
||||
text->newUuid();
|
||||
}
|
||||
else if (auto image = dynamic_cast<DiagramImageItem *>(item)) {
|
||||
image->newUuid();
|
||||
}
|
||||
else if (auto shape = dynamic_cast<QetShapeItem *>(item)) {
|
||||
shape->newUuid();
|
||||
}
|
||||
else if (Conductor *cond = dynamic_cast<Conductor *>(item)) {
|
||||
// Same reasoning for conductors: conductor.uuid is the PRIMARY
|
||||
// KEY of the conductor table, and its insert is a plain INSERT,
|
||||
|
||||
@@ -22,8 +22,11 @@
|
||||
#include "qetapp.h"
|
||||
#include "qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "qetgraphicsitem/elementtextitemgroup.h"
|
||||
#include "qetdiagrameditor.h"
|
||||
#include "textgrid.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QSettings>
|
||||
|
||||
/**
|
||||
@brief ElementTextsMover::ElementTextsMover
|
||||
@@ -80,6 +83,19 @@ int ElementTextsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_ite
|
||||
return -1;
|
||||
|
||||
m_movement_running = true;
|
||||
|
||||
m_status_bar.clear();
|
||||
if (!diagram->views().isEmpty())
|
||||
if (const auto qde = QETApp::diagramEditorAncestorOf(diagram->views().at(0)))
|
||||
m_status_bar = qde->statusBar();
|
||||
if (m_status_bar)
|
||||
{
|
||||
const qreal divisor = QSettings().value(TextGrid::settings_key, 1).toReal();
|
||||
m_status_bar->showMessage(divisor > 0
|
||||
? QObject::tr("Grille des textes %1. Relâcher Maj et maintenir Ctrl pour placer librement.")
|
||||
.arg(TextGrid::ratioLabel(divisor))
|
||||
: QObject::tr("Grille des textes désactivée."));
|
||||
}
|
||||
|
||||
return m_items_hash.size();
|
||||
}
|
||||
@@ -101,7 +117,7 @@ void ElementTextsMover::continueMovement(QGraphicsSceneMouseEvent *event)
|
||||
button_down_parent_pos = qgi->mapToParent(qgi->mapFromScene(event->buttonDownScenePos(Qt::LeftButton)));
|
||||
|
||||
QPointF new_pos = m_items_hash.value(qgi) + current_parent_pos - button_down_parent_pos;
|
||||
event->modifiers() == Qt::ControlModifier ? qgi->setPos(new_pos) : qgi->setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? qgi->setPos(new_pos) : qgi->setPos(Diagram::snapToTextGrid(new_pos));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +128,19 @@ void ElementTextsMover::continueMovement(QGraphicsSceneMouseEvent *event)
|
||||
void ElementTextsMover::endMovement()
|
||||
{
|
||||
//No movement or no items to move
|
||||
if (m_status_bar)
|
||||
m_status_bar->clearMessage();
|
||||
|
||||
if(!m_movement_running || m_items_hash.isEmpty())
|
||||
return;
|
||||
|
||||
//Movement is null
|
||||
QGraphicsItem *qgi = m_items_hash.keys().first();
|
||||
if(qgi->pos() == m_items_hash.value(qgi))
|
||||
{
|
||||
m_movement_running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
QUndoCommand *undo = new QUndoCommand(undoText());
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
#include <QSet>
|
||||
#include <QPointF>
|
||||
#include <QHash>
|
||||
#include <QPointer>
|
||||
#include <QStatusBar>
|
||||
|
||||
class QGraphicsItem;
|
||||
class DiagramTextItem;
|
||||
@@ -55,6 +57,7 @@ class ElementTextsMover
|
||||
QHash <DiagramTextItem *, QPointF> m_texts_hash;
|
||||
QHash <QGraphicsItemGroup *, QPointF> m_grps_hash;
|
||||
QHash <QGraphicsItem *, QPointF> m_items_hash;
|
||||
QPointer<QStatusBar> m_status_bar;
|
||||
int m_text_count = 0,
|
||||
m_group_count = 0;
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
#include "qetmessagebox.h"
|
||||
|
||||
#include <QGraphicsSimpleTextItem>
|
||||
#include <QSet>
|
||||
#include <QSvgGenerator>
|
||||
#include <QtXml>
|
||||
#include <cmath>
|
||||
@@ -483,7 +484,24 @@ void ExportDialog::generateDxf(
|
||||
// QList <Terminal *> list_terminals;
|
||||
|
||||
// Determine les elements a "XMLiser"
|
||||
foreach(QGraphicsItem *qgi, diagram -> items()) {
|
||||
// In stacking order (z, then insertion order, which a load makes the
|
||||
// file's order), not items() order: that follows memory addresses, so
|
||||
// the same folio came out in a different order on every export, as
|
||||
// saving did before bugtracker #343. A rect query gives stacking order
|
||||
// even with NoIndex; anything it misses keeps its items() place after
|
||||
// the rest.
|
||||
QList<QGraphicsItem *> stacked_items = diagram -> items(
|
||||
QRectF(-1e9, -1e9, 2e9, 2e9), Qt::IntersectsItemBoundingRect,
|
||||
Qt::AscendingOrder);
|
||||
{
|
||||
const QSet<QGraphicsItem *> ranked(stacked_items.cbegin(), stacked_items.cend());
|
||||
for (QGraphicsItem *qgi : diagram -> items()) {
|
||||
if (!ranked.contains(qgi)) {
|
||||
stacked_items << qgi;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (QGraphicsItem *qgi : std::as_const(stacked_items)) {
|
||||
if (Element *elmt = qgraphicsitem_cast<Element *>(qgi)) {
|
||||
list_elements << elmt;
|
||||
} else if (Conductor *f = qgraphicsitem_cast<Conductor *>(qgi)) {
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
#include "../qetgraphicsitem/ViewItem/qetgraphicstableitem.h"
|
||||
#include "../qetgraphicsitem/ViewItem/ui/graphicstablepropertieseditor.h"
|
||||
#include "../qetgraphicsitem/ViewItem/ui/projectdbmodelpropertieswidget.h"
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
#include "../qetgraphicsitem/conductortextitem.h"
|
||||
#include "../qetgraphicsitem/diagramimageitem.h"
|
||||
#include "../qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "../qetgraphicsitem/element.h"
|
||||
@@ -30,11 +32,13 @@
|
||||
#include "../qetgraphicsitem/qetshapeitem.h"
|
||||
#include "../ui/dynamicelementtextitemeditor.h"
|
||||
#include "../ui/elementpropertieswidget.h"
|
||||
#include "../ui/conductorpropertieseditorwidget.h"
|
||||
#include "../ui/imagepropertieswidget.h"
|
||||
#include "../ui/inditextpropertieswidget.h"
|
||||
#include "../ui/shapegraphicsitempropertieswidget.h"
|
||||
|
||||
#include <QGraphicsItem>
|
||||
#include <QSettings>
|
||||
|
||||
/**
|
||||
@brief PropertiesEditorFactory::propertiesEditor
|
||||
@@ -83,6 +87,17 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor(
|
||||
return nullptr;
|
||||
}
|
||||
QGraphicsItem *item = items.first();
|
||||
|
||||
//Selecting a conductor's text label edits its parent conductor (#500),
|
||||
//mirroring how double-clicking the label opens the conductor dialog.
|
||||
if (count_ == 1) {
|
||||
if (auto *cti = qgraphicsitem_cast<ConductorTextItem *>(item)) {
|
||||
if (Conductor *parent_cond = cti->parentConductor()) {
|
||||
items = {parent_cond};
|
||||
item = parent_cond;
|
||||
}
|
||||
}
|
||||
}
|
||||
const int type_ = item->type();
|
||||
|
||||
//The editor widget can only edit one item
|
||||
@@ -100,6 +115,28 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor(
|
||||
|
||||
switch (type_)
|
||||
{
|
||||
case Conductor::Type: //1001
|
||||
{
|
||||
//Off unless enabled in the preferences (General page): selecting
|
||||
//a conductor then brings up nothing in the dock, as before.
|
||||
if (!QSettings().value(
|
||||
QStringLiteral("diagrameditor/conductor_properties_panel"),
|
||||
false).toBool()) {
|
||||
return nullptr;
|
||||
}
|
||||
//Prototype (#500): single-conductor editing in the dock.
|
||||
if (count_ > 1) {
|
||||
return nullptr;
|
||||
}
|
||||
auto conductor = static_cast<Conductor*>(item);
|
||||
|
||||
if (class_name == ConductorPropertiesEditorWidget::staticMetaObject.className())
|
||||
{
|
||||
static_cast<ConductorPropertiesEditorWidget*>(editor)->setConductor(conductor);
|
||||
return editor;
|
||||
}
|
||||
return new ConductorPropertiesEditorWidget(conductor, parent);
|
||||
}
|
||||
case Element::Type: //1000
|
||||
{
|
||||
if (count_ > 1) {
|
||||
|
||||
@@ -80,6 +80,11 @@ class PaletteGraphicsView : public QGraphicsView
|
||||
nothing by default.
|
||||
*/
|
||||
virtual void paintingInverted(bool inverted);
|
||||
/// @return the painter the scene really paints with: the
|
||||
/// off-screen image's while painting inverted, else \a painter.
|
||||
/// For subclasses that draw more in drawBackground().
|
||||
QPainter *scenePainter(QPainter *painter)
|
||||
{ return m_inverting ? &m_buffer_painter : painter; }
|
||||
|
||||
private:
|
||||
void paintInverted(QPaintEvent *event);
|
||||
|
||||
@@ -388,6 +388,29 @@ void convertUriToGoTo(const QString &pdfPath)
|
||||
out.close();
|
||||
}
|
||||
|
||||
void removeUnusedPdfxNamespace(const QString &pdfPath)
|
||||
{
|
||||
QFile f(pdfPath);
|
||||
if (!f.open(QIODevice::ReadOnly)) return;
|
||||
QByteArray data = f.readAll();
|
||||
f.close();
|
||||
|
||||
// Qt only fills the pdfxid namespace in when it writes PDF/X-4.
|
||||
if (data.contains("pdfxid:GTS_PDFXVersion")) return;
|
||||
|
||||
static const QByteArray decl =
|
||||
" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"";
|
||||
const int pos = data.indexOf(decl);
|
||||
if (pos == -1) return;
|
||||
|
||||
// Same length, so the stream /Length and the xref offsets stay valid.
|
||||
data.replace(pos, decl.size(), QByteArray(decl.size(), ' '));
|
||||
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return;
|
||||
f.write(data);
|
||||
f.close();
|
||||
}
|
||||
|
||||
void convertComponentInfoAnnotations(const QString &pdfPath,
|
||||
const QList<ComponentInfo> &annotations)
|
||||
{
|
||||
|
||||
@@ -74,6 +74,15 @@ namespace PdfLinks {
|
||||
*/
|
||||
void convertUriToGoTo(const QString &pdfPath);
|
||||
|
||||
/**
|
||||
Post-process a Qt-generated PDF file: blank out the PDF/X namespace
|
||||
declaration Qt 6 writes into the XMP metadata of every PDF. Adobe
|
||||
Acrobat draws small text too bold when it is present (bugtracker #340).
|
||||
Replaced in place with spaces, so no offset changes. No-op for a real
|
||||
PDF/X file or when the declaration is absent.
|
||||
*/
|
||||
void removeUnusedPdfxNamespace(const QString &pdfPath);
|
||||
|
||||
struct ComponentInfo {
|
||||
QString contents;
|
||||
};
|
||||
|
||||
@@ -874,6 +874,7 @@ void ProjectPrintWindow::print()
|
||||
// Convert URI link annotations into native internal GoTo/FitR
|
||||
// actions so cross-references jump inside the document.
|
||||
PdfLinks::convertUriToGoTo(pdfFile);
|
||||
PdfLinks::removeUnusedPdfxNamespace(pdfFile);
|
||||
|
||||
this->close();
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ XRefProperties::XRefProperties()
|
||||
{
|
||||
m_show_power_ctc = true;
|
||||
m_show_terminal_name = true;
|
||||
m_show_all_configured_slaves = false;
|
||||
m_display = Cross;
|
||||
m_snap_to = Bottom;
|
||||
m_prefix_keys << "power" << "delay" << "switch";
|
||||
@@ -51,6 +52,7 @@ void XRefProperties::toSettings(QSettings &settings,
|
||||
{
|
||||
settings.setValue(prefix % "showpowerctc", m_show_power_ctc);
|
||||
settings.setValue(prefix % "showterminalname", m_show_terminal_name);
|
||||
settings.setValue(prefix % "showallconfiguredslaves", m_show_all_configured_slaves);
|
||||
QString display = m_display == Cross? "cross" : "contacts";
|
||||
settings.setValue(prefix % "displayhas", display);
|
||||
QString snap = m_snap_to == Bottom? "bottom" : "label";
|
||||
@@ -84,6 +86,7 @@ void XRefProperties::fromSettings(const QSettings &settings,
|
||||
{
|
||||
m_show_power_ctc = settings.value(prefix % "showpowerctc", true).toBool();
|
||||
m_show_terminal_name = settings.value(prefix % "showterminalname", true).toBool();
|
||||
m_show_all_configured_slaves = settings.value(prefix % "showallconfiguredslaves", false).toBool();
|
||||
QString display = settings.value(prefix % "displayhas", "cross").toString();
|
||||
display == "cross"? m_display = Cross : m_display = Contacts;
|
||||
QString snap = settings.value(prefix % "snapto", "label").toString();
|
||||
@@ -115,6 +118,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const
|
||||
|
||||
xml_element.setAttribute("showpowerctc", m_show_power_ctc? "true" : "false");
|
||||
xml_element.setAttribute("showterminalname", m_show_terminal_name? "true" : "false");
|
||||
xml_element.setAttribute("showallconfiguredslaves", m_show_all_configured_slaves? "true" : "false");
|
||||
QString display = m_display == Cross? "cross" : "contacts";
|
||||
xml_element.setAttribute("displayhas", display);
|
||||
QString snap = m_snap_to == Bottom? "bottom" : "label";
|
||||
@@ -147,6 +151,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const
|
||||
bool XRefProperties::fromXml(const QDomElement &xml_element) {
|
||||
m_show_power_ctc = xml_element.attribute("showpowerctc") == "true";
|
||||
m_show_terminal_name = xml_element.attribute("showterminalname", "true") == "true";
|
||||
m_show_all_configured_slaves = xml_element.attribute("showallconfiguredslaves", "false") == "true";
|
||||
QString display = xml_element.attribute("displayhas", "cross");
|
||||
display == "cross"? m_display = Cross : m_display = Contacts;
|
||||
QString snap = xml_element.attribute("snapto", "label");
|
||||
@@ -200,6 +205,7 @@ QHash<QString, XRefProperties> XRefProperties::defaultProperties()
|
||||
bool XRefProperties::operator ==(const XRefProperties &xrp) const{
|
||||
return (m_show_power_ctc == xrp.m_show_power_ctc
|
||||
&& m_show_terminal_name == xrp.m_show_terminal_name
|
||||
&& m_show_all_configured_slaves == xrp.m_show_all_configured_slaves
|
||||
&& m_display == xrp.m_display
|
||||
&& m_snap_to == xrp.m_snap_to
|
||||
&& m_prefix == xrp.m_prefix
|
||||
|
||||
@@ -60,6 +60,9 @@ class XRefProperties : public PropertiesInterface
|
||||
void setShowTerminalName (const bool a) {m_show_terminal_name = a;}
|
||||
bool showTerminalName () const {return m_show_terminal_name;}
|
||||
|
||||
void setShowAllConfiguredSlaves (const bool a) {m_show_all_configured_slaves = a;}
|
||||
bool showAllConfiguredSlaves () const {return m_show_all_configured_slaves;}
|
||||
|
||||
void setDisplayHas (const DisplayHas dh) {m_display = dh;}
|
||||
DisplayHas displayHas () const {return m_display;}
|
||||
|
||||
@@ -88,6 +91,7 @@ class XRefProperties : public PropertiesInterface
|
||||
private:
|
||||
bool m_show_power_ctc;
|
||||
bool m_show_terminal_name;
|
||||
bool m_show_all_configured_slaves;
|
||||
DisplayHas m_display;
|
||||
SnapTo m_snap_to;
|
||||
Qt::AlignmentFlag m_xref_pos;
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
#include "ui/aboutqetdialog.h"
|
||||
#include "ui/configpage/generalconfigurationpage.h"
|
||||
#include "ui/configpage/shortcutsconfigpage.h"
|
||||
#include "ui/configpage/shortcutbarconfigpage.h"
|
||||
#include "machine_info.h"
|
||||
#include "TerminalStrip/ui/terminalstripeditorwindow.h"
|
||||
#include "qetversion.h"
|
||||
@@ -2213,6 +2214,7 @@ void QETApp::configureQET()
|
||||
cd.addPage(new ExportConfigPage());
|
||||
cd.addPage(new PrintConfigPage());
|
||||
cd.addPage(new ShortcutsConfigPage());
|
||||
cd.addPage(new ShortcutBarConfigPage());
|
||||
#ifdef QET_SPACEMOUSE_SUPPORT
|
||||
cd.addPage(new SpaceMouseConfigPage());
|
||||
#endif
|
||||
@@ -2231,6 +2233,7 @@ void QETApp::configureQET()
|
||||
// affiche le dialogue puis evite de le lier a un quelconque widget parent
|
||||
cd.exec();
|
||||
cd.setParent(nullptr, cd.windowFlags());
|
||||
emit textGridChanged();
|
||||
|
||||
#ifdef QET_SPACEMOUSE_SUPPORT
|
||||
if (m_space_mouse_listener) {
|
||||
|
||||
@@ -260,6 +260,10 @@ class QETApp : public QObject
|
||||
|
||||
static QString m_interface_language;
|
||||
|
||||
signals:
|
||||
/// The text grid setting changed, see TextGrid.
|
||||
void textGridChanged();
|
||||
|
||||
public slots:
|
||||
void systray(QSystemTrayIcon::ActivationReason);
|
||||
void reduceEveryEditor();
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
#include "scripting/qetscripting.h"
|
||||
#endif
|
||||
#include <QCoreApplication>
|
||||
#include <QToolButton>
|
||||
#include "ElementsCollection/elementscollectionwidget.h"
|
||||
#include "ElementsCollection/elementpickerpopup.h"
|
||||
#include "shortcutbarsettings.h"
|
||||
#include "qetgraphicsitem/conductor.h"
|
||||
#include "commandsearchpopup.h"
|
||||
#include "QWidgetAnimation/qwidgetanimation.h"
|
||||
#include "autoNum/ui/autonumberingdockwidget.h"
|
||||
#include "conductornumexport.h"
|
||||
@@ -48,6 +53,7 @@
|
||||
#include "qeticons.h"
|
||||
#include "qetmessagebox.h"
|
||||
#include "recentfiles.h"
|
||||
#include "textgrid.h"
|
||||
#include "shortcutmanager.h"
|
||||
#include "ui/bomexportdialog.h"
|
||||
#include "ui/conductorcolortoolbutton.h"
|
||||
@@ -60,6 +66,7 @@
|
||||
#include "undocommand/addelementtextcommand.h"
|
||||
#include "utils/qetsettings.h"
|
||||
#include "utils/qetutils.h"
|
||||
#include "undocommand/alignselectioncommand.h"
|
||||
#include "undocommand/rotateselectioncommand.h"
|
||||
#include "undocommand/rotatetextscommand.h"
|
||||
#include "diagram.h"
|
||||
@@ -73,6 +80,7 @@
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QTimer>
|
||||
#include <algorithm>
|
||||
#ifdef BUILD_WITHOUT_KF
|
||||
# include "ui/nokde/kautosavefile.h"
|
||||
#else
|
||||
@@ -89,6 +97,7 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) :
|
||||
QETMainWindow(parent),
|
||||
m_row_column_actions_group (this),
|
||||
m_selection_actions_group (this),
|
||||
m_align_actions_group (this),
|
||||
m_add_item_actions_group (this),
|
||||
m_zoom_actions_group (this),
|
||||
m_select_actions_group (this),
|
||||
@@ -226,6 +235,13 @@ void QETDiagramEditor::setUpElementsCollectionWidget()
|
||||
m_qdw_elmt_collection->setWidget(m_element_collection_widget);
|
||||
m_element_collection_widget->expandFirstItems();
|
||||
|
||||
//The widget does not know which view should receive the element -- it
|
||||
//is also used by the picker popup, which has no editor ancestor -- so
|
||||
//the host decides.
|
||||
connect(m_element_collection_widget,
|
||||
&ElementsCollectionWidget::insertElementRequested,
|
||||
this, &QETDiagramEditor::insertElementFromCollection);
|
||||
|
||||
addDockWidget(Qt::RightDockWidgetArea, m_qdw_elmt_collection);
|
||||
}
|
||||
|
||||
@@ -367,10 +383,18 @@ void QETDiagramEditor::setUpActions()
|
||||
//original, where it was easy to miss entirely; now it appears
|
||||
//under the cursor and follows it until a click, Return, or Escape
|
||||
//to cancel -- the same interaction as placing a new element.
|
||||
const QPoint view_pos = dv->viewport()->mapFromGlobal(QCursor::pos());
|
||||
const QPointF start_pos = dv->viewport()->rect().contains(view_pos)
|
||||
? dv->mapToScene(view_pos)
|
||||
: dv->mapToScene(dv->viewport()->rect().center());
|
||||
//
|
||||
//dv->lastMousePos() (an ordinary Qt mouse-move position), not
|
||||
//QCursor::pos() (a global, OS-level cursor query): several
|
||||
//window managers and compositors -- Wayland in particular --
|
||||
//silently refuse that query, returning a stale or wrong
|
||||
//position, which is exactly what made the pasted content land
|
||||
//far from the cursor instead of under it.
|
||||
const QPoint last_pos = dv->lastMousePos();
|
||||
const QPoint view_pos = (last_pos.x() >= 0 && dv->viewport()->rect().contains(last_pos))
|
||||
? last_pos
|
||||
: dv->viewport()->rect().center();
|
||||
const QPointF start_pos = dv->mapToScene(view_pos);
|
||||
|
||||
dv->diagram()->setEventInterface(
|
||||
new DiagramEventAddPaste(dv->diagram(), start_pos));
|
||||
@@ -473,6 +497,33 @@ void QETDiagramEditor::setUpActions()
|
||||
}
|
||||
});
|
||||
|
||||
//Snap step for dragged texts, as a fraction of the folio grid
|
||||
m_text_grid_menu = new QMenu(tr("Grille des textes"), this);
|
||||
m_text_grid_menu->setIcon(QET::Icons::Grid);
|
||||
m_text_grid_menu->setToolTipsVisible(true);
|
||||
m_text_grid_button = new QToolButton(this);
|
||||
m_text_grid_button->setMenu(m_text_grid_menu);
|
||||
m_text_grid_button->setPopupMode(QToolButton::InstantPopup);
|
||||
m_text_grid_button->setToolButtonStyle(Qt::ToolButtonTextOnly);
|
||||
m_text_grid_button->setToolTip(tr("Grille d'accrochage des textes déplacés à la souris.\n"
|
||||
"Maintenir Ctrl pendant le déplacement pour placer librement."));
|
||||
auto text_grid_group = new QActionGroup(this);
|
||||
for (const qreal divisor : TextGrid::divisors)
|
||||
{
|
||||
QAction *action = m_text_grid_menu->addAction(
|
||||
divisor > 0 ? TextGrid::ratioLabel(divisor) : tr("Désactivée"));
|
||||
action->setCheckable(true);
|
||||
action->setData(divisor);
|
||||
text_grid_group->addAction(action);
|
||||
}
|
||||
connect(text_grid_group, &QActionGroup::triggered, this, [](QAction *action) {
|
||||
QSettings().setValue(TextGrid::settings_key, action->data());
|
||||
emit QETApp::instance()->textGridChanged();
|
||||
});
|
||||
connect(QETApp::instance(), &QETApp::textGridChanged,
|
||||
this, &QETDiagramEditor::updateTextGridButton);
|
||||
updateTextGridButton();
|
||||
|
||||
// Draw or not the custom guides
|
||||
m_draw_guides = new QAction ( QIcon::fromTheme("guides"), tr("Afficher les guides"), this);
|
||||
m_draw_guides->setStatusTip(tr("Affiche ou masque les guides"));
|
||||
@@ -485,6 +536,30 @@ void QETDiagramEditor::setUpActions()
|
||||
}
|
||||
});
|
||||
|
||||
//Keep the column numbers and row letters of the folio in sight
|
||||
m_cell_rulers = new QAction(tr("Garder les en-têtes visibles"), this);
|
||||
m_cell_rulers->setStatusTip(tr("Garde les numéros de colonne et les lettres de ligne du folio visibles au bord de la vue"));
|
||||
m_cell_rulers->setCheckable(true);
|
||||
m_cell_rulers->setChecked(settings.value("diagrameditor/cell_rulers", false).toBool());
|
||||
connect(m_cell_rulers, &QAction::triggered, [this](bool checked) {
|
||||
QSettings().setValue("diagrameditor/cell_rulers", checked);
|
||||
foreach (ProjectView *prjv, this->openedProjects())
|
||||
foreach (DiagramView *dv, prjv->diagram_views())
|
||||
dv->setCellRulersShown(checked);
|
||||
});
|
||||
|
||||
//Draw the limits of the folio columns and rows across the drawing
|
||||
m_cell_lines = new QAction(tr("Afficher les limites des cases"), this);
|
||||
m_cell_lines->setStatusTip(tr("Trace les limites des colonnes et des lignes du folio sur le schéma, à l'écran seulement"));
|
||||
m_cell_lines->setCheckable(true);
|
||||
m_cell_lines->setChecked(settings.value("diagrameditor/cell_lines", false).toBool());
|
||||
connect(m_cell_lines, &QAction::triggered, [this](bool checked) {
|
||||
QSettings().setValue("diagrameditor/cell_lines", checked);
|
||||
foreach (ProjectView *prjv, this->openedProjects())
|
||||
foreach (DiagramView *dv, prjv->diagram_views())
|
||||
dv->setCellLinesShown(checked);
|
||||
});
|
||||
|
||||
//Edit current diagram properties
|
||||
m_edit_diagram_properties = new QAction(QET::Icons::DialogInformation, tr("Propriétés du folio"), this);
|
||||
ShortcutManager::instance().registerAction(m_edit_diagram_properties, "diagrameditor.edit_diagram_properties", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_L);
|
||||
@@ -747,6 +822,91 @@ void QETDiagramEditor::setUpActions()
|
||||
ShortcutManager::instance().registerAction(m_rotate_texts, "diagrameditor.rotate_texts", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_Space);
|
||||
ShortcutManager::instance().registerAction(m_edit_selection, "diagrameditor.edit_selection", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_E);
|
||||
|
||||
//Re-enter placement mode with the element placed last. Bare A rather
|
||||
//than Space: Space already rotates the pending element *inside*
|
||||
//placement mode (diagrameventaddelement.cpp), and is taken three times
|
||||
//over in this editor besides. A matches KiCad's add-symbol key and
|
||||
//reads correctly in the source language ("Ajouter"). ShortcutManager
|
||||
//makes it a default, not a commitment -- it appears in the Shortcuts
|
||||
//preference page like every other binding.
|
||||
m_insert_last_element = new QAction(QET::Icons::ElementNew,
|
||||
tr("Insérer le dernier élément"), this);
|
||||
m_insert_last_element->setStatusTip(
|
||||
tr("Place à nouveau le dernier élément inséré", "status bar tip"));
|
||||
m_insert_last_element->setData("insert_last_element");
|
||||
m_insert_last_element->setEnabled(false);
|
||||
ShortcutManager::instance().registerAction(
|
||||
m_insert_last_element, "diagrameditor.insert_last_element",
|
||||
tr("Éditeur de schémas"), Qt::Key_A);
|
||||
connect(m_insert_last_element, &QAction::triggered,
|
||||
this, &QETDiagramEditor::insertLastElement);
|
||||
addAction(m_insert_last_element);
|
||||
//Type to find and run any command, as SolidWorks' "Search Commands"
|
||||
//and the command palette of many editors. Ctrl+Shift+P, the key those
|
||||
//editors use, is taken by the autonumbering dock; M for "menu".
|
||||
m_command_search = new QAction(tr("Rechercher une commande…"), this);
|
||||
m_command_search->setStatusTip(
|
||||
tr("Tapez une partie du nom d'une commande et appuyez sur Entrée pour la lancer",
|
||||
"status bar tip"));
|
||||
ShortcutManager::instance().registerAction(
|
||||
m_command_search, "diagrameditor.command_search",
|
||||
tr("Éditeur de schémas"), Qt::CTRL | Qt::SHIFT | Qt::Key_M);
|
||||
connect(m_command_search, &QAction::triggered, this, [this]() {
|
||||
if (!m_command_search_popup) {
|
||||
m_command_search_popup = new CommandSearchPopup(this);
|
||||
}
|
||||
const QRect area = geometry();
|
||||
m_command_search_popup->popUpAt(
|
||||
area.contains(QCursor::pos()) ? QCursor::pos()
|
||||
: area.center());
|
||||
});
|
||||
addAction(m_command_search);
|
||||
|
||||
//Cursor-anchored picker. Insert is unbound anywhere in the tree and
|
||||
//reads correctly for the action, which keeps A free for the far more
|
||||
//frequent "place the same symbol again".
|
||||
m_show_element_picker = new QAction(QET::Icons::Add,
|
||||
tr("Insérer un élément…"), this);
|
||||
m_show_element_picker->setStatusTip(
|
||||
tr("Ouvre le sélecteur d'éléments à la position du curseur",
|
||||
"status bar tip"));
|
||||
m_show_element_picker->setData("show_element_picker");
|
||||
ShortcutManager::instance().registerAction(
|
||||
m_show_element_picker, "diagrameditor.show_element_picker",
|
||||
tr("Éditeur de schémas"), Qt::Key_Insert);
|
||||
connect(m_show_element_picker, &QAction::triggered,
|
||||
this, &QETDiagramEditor::showElementPicker);
|
||||
addAction(m_show_element_picker);
|
||||
|
||||
//The picker with a row of commands above it, chosen by what is
|
||||
//selected -- the SolidWorks "S" shortcut bar. S is unbound in this
|
||||
//editor.
|
||||
m_show_shortcut_bar = new QAction(tr("Barre de raccourcis"), this);
|
||||
m_show_shortcut_bar->setStatusTip(
|
||||
tr("Ouvre à la position du curseur les commandes utiles pour la sélection, et le sélecteur d'éléments",
|
||||
"status bar tip"));
|
||||
ShortcutManager::instance().registerAction(
|
||||
m_show_shortcut_bar, "diagrameditor.show_shortcut_bar",
|
||||
tr("Éditeur de schémas"), Qt::Key_S);
|
||||
connect(m_show_shortcut_bar, &QAction::triggered,
|
||||
this, &QETDiagramEditor::showShortcutBar);
|
||||
addAction(m_show_shortcut_bar);
|
||||
|
||||
//Enter on the folio repeats it (DiagramView::keyPressEvent). That is
|
||||
//handled by the view rather than bound here, so Enter keeps working
|
||||
//in the search fields, the collection tree and every dialog.
|
||||
m_repeat_last_command = new QAction(tr("Répéter la dernière commande"), this);
|
||||
m_repeat_last_command->setStatusTip(
|
||||
tr("Relance le dernier outil de dessin ou la dernière insertion d'élément (Entrée sur le folio)",
|
||||
"status bar tip"));
|
||||
m_repeat_last_command->setEnabled(false);
|
||||
ShortcutManager::instance().registerAction(
|
||||
m_repeat_last_command, "diagrameditor.repeat_last_command",
|
||||
tr("Éditeur de schémas"), QKeySequence());
|
||||
connect(m_repeat_last_command, &QAction::triggered,
|
||||
this, &QETDiagramEditor::repeatLastCommand);
|
||||
addAction(m_repeat_last_command);
|
||||
|
||||
m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip"));
|
||||
m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip"));
|
||||
m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip"));
|
||||
@@ -763,6 +923,15 @@ void QETDiagramEditor::setUpActions()
|
||||
|
||||
connect(&m_selection_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::selectionGroupTriggered);
|
||||
|
||||
//Align actions. No default shortcut: they are reached from the
|
||||
//Edit menu, the selection's context menu and the command search,
|
||||
//and a user can bind one if they want.
|
||||
QAction *snap_to_grid = m_align_actions_group.addAction(tr("Aligner sur la grille"));
|
||||
ShortcutManager::instance().registerAction(snap_to_grid, "diagrameditor.snap_selection_to_grid", tr("Éditeur de schémas"), QKeySequence());
|
||||
snap_to_grid->setStatusTip(tr("Remet les éléments, images et textes sélectionnés sur la grille", "status bar tip"));
|
||||
snap_to_grid->setData("snap_selection_to_grid");
|
||||
connect(&m_align_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::alignGroupTriggered);
|
||||
|
||||
//Select Action
|
||||
QAction *select_all = m_select_actions_group.addAction( QET::Icons::EditSelectAll, tr("Tout sélectionner") );
|
||||
QAction *select_nothing = m_select_actions_group.addAction( QET::Icons::EditSelectNone, tr("Désélectionner tout") );
|
||||
@@ -878,6 +1047,14 @@ void QETDiagramEditor::setUpActions()
|
||||
add_path->setCheckable(true);
|
||||
|
||||
connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered);
|
||||
//No default key, but an id: they can then be found by the command
|
||||
//search, bound in the Shortcuts page and placed on the shortcut bar,
|
||||
//like every other command.
|
||||
for (QAction *action : m_add_item_actions_group.actions()) {
|
||||
ShortcutManager::instance().registerAction(
|
||||
action, "diagrameditor.add_" + action->data().toString(),
|
||||
tr("Éditeur de schémas"), QKeySequence());
|
||||
}
|
||||
|
||||
//Depth action
|
||||
m_depth_action_group = QET::depthActionGroup(this);
|
||||
@@ -948,6 +1125,7 @@ void QETDiagramEditor::setUpToolBar()
|
||||
view_tool_bar -> addWidget(new DiagramEditorHandlerSizeWidget(this));
|
||||
view_tool_bar -> addSeparator();
|
||||
view_tool_bar -> addAction(m_draw_grid);
|
||||
view_tool_bar -> addWidget(m_text_grid_button);
|
||||
view_tool_bar -> addAction(m_draw_guides);
|
||||
view_tool_bar -> addWidget(m_background_color_button);
|
||||
view_tool_bar -> addSeparator();
|
||||
@@ -1030,24 +1208,37 @@ void QETDiagramEditor::setUpMenu()
|
||||
menu_edition -> addAction(m_paste);
|
||||
menu_edition -> addAction(m_duplicate);
|
||||
menu_edition -> addAction(m_configure_duplicate);
|
||||
menu_edition -> addAction(m_insert_last_element);
|
||||
menu_edition -> addAction(m_show_element_picker);
|
||||
menu_edition -> addAction(m_show_shortcut_bar);
|
||||
menu_edition -> addAction(m_repeat_last_command);
|
||||
menu_edition -> addAction(m_command_search);
|
||||
menu_edition -> addSeparator();
|
||||
//The same actions the "Ajouter" toolbar holds. They were toolbar-only,
|
||||
//which left them unreachable for anyone working without a mouse: a
|
||||
//toolbar button has no key, so text fields, images and every drawing
|
||||
//shape simply could not be added. m_depth_action_group below has
|
||||
//always been in both places; this brings these into line with it.
|
||||
QMenu *menu_add_item = menu_edition -> addMenu(tr("A&jouter"));
|
||||
menu_add_item -> setIcon(QET::Icons::Add);
|
||||
menu_add_item -> addActions(m_add_item_actions_group.actions());
|
||||
m_add_item_menu = menu_edition -> addMenu(tr("A&jouter"));
|
||||
m_add_item_menu -> setIcon(QET::Icons::Add);
|
||||
m_add_item_menu -> addActions(m_add_item_actions_group.actions());
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addActions(m_select_actions_group.actions());
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addActions(m_selection_actions_group.actions());
|
||||
m_align_menu = menu_edition -> addMenu(tr("Aligner"));
|
||||
m_align_menu -> addActions(m_align_actions_group.actions());
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addAction(m_conductor_reset);
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addAction(m_edit_diagram_properties);
|
||||
menu_edition -> addActions(m_row_column_actions_group.actions());
|
||||
//Not added to a menu here: it exists so the folio's context menu can
|
||||
//hold the row and column actions one level down (see
|
||||
//DiagramView::contextMenuActions()).
|
||||
m_row_column_menu = new QMenu(tr("Lignes et colonnes"), this);
|
||||
m_row_column_menu -> setIcon(QET::Icons::EditTableInsertColumnRight);
|
||||
m_row_column_menu -> addActions(m_row_column_actions_group.actions());
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addActions(m_depth_action_group->actions());
|
||||
menu_edition -> addSeparator();
|
||||
@@ -1101,7 +1292,10 @@ void QETDiagramEditor::setUpMenu()
|
||||
menu_affichage -> addAction(m_mode_visualise);
|
||||
menu_affichage -> addSeparator();
|
||||
menu_affichage -> addAction(m_draw_grid);
|
||||
menu_affichage -> addMenu(m_text_grid_menu);
|
||||
menu_affichage -> addAction(m_draw_guides);
|
||||
menu_affichage -> addAction(m_cell_rulers);
|
||||
menu_affichage -> addAction(m_cell_lines);
|
||||
menu_affichage -> addMenu(m_background_color_button->menu());
|
||||
menu_affichage -> addSeparator();
|
||||
menu_affichage -> addActions(m_zoom_actions_group.actions());
|
||||
@@ -1736,6 +1930,8 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action)
|
||||
return;
|
||||
}
|
||||
|
||||
setLastCommand(action);
|
||||
|
||||
DiagramEventInterface *diagram_event = nullptr;
|
||||
|
||||
if (value == "line")
|
||||
@@ -1882,6 +2078,45 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::alignGroupTriggered
|
||||
Run the align action @a action on the selection of the current diagram,
|
||||
and say in the status bar what it did, including when there was nothing
|
||||
to do or when locked items were left in place.
|
||||
@param action
|
||||
*/
|
||||
void QETDiagramEditor::alignGroupTriggered(QAction *action)
|
||||
{
|
||||
DiagramView *dv = currentDiagramView();
|
||||
if (!dv || action->data().toString() != QLatin1String("snap_selection_to_grid"))
|
||||
return;
|
||||
|
||||
Diagram *diagram = dv->diagram();
|
||||
auto *command = new AlignSelectionCommand(diagram, AlignSelectionCommand::SnapToGrid);
|
||||
const int locked = command->lockedCount();
|
||||
|
||||
QString message;
|
||||
if (command->isValid())
|
||||
{
|
||||
message = tr("%n objet(s) remis sur la grille", "", command->movedCount());
|
||||
diagram->undoStack().push(command);
|
||||
}
|
||||
else
|
||||
{
|
||||
message = tr("La sélection est déjà sur la grille");
|
||||
delete command;
|
||||
}
|
||||
if (locked)
|
||||
message += QLatin1Char(' ') + tr("(%n objet(s) verrouillé(s) laissé(s) en place)", "", locked);
|
||||
|
||||
//Queued, not shown at once: when the action comes from a menu or the
|
||||
//command search, closing it queues events that would clear a message
|
||||
//shown right now.
|
||||
QTimer::singleShot(0, this, [this, message]() {
|
||||
statusBar()->showMessage(message, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
void QETDiagramEditor::rowColumnGroupTriggered(QAction *action)
|
||||
{
|
||||
QString value = action->data().toString();
|
||||
@@ -1929,10 +2164,17 @@ void QETDiagramEditor::slot_updateActions()
|
||||
m_zoom_actions_group. setEnabled(opened_diagram);
|
||||
m_select_actions_group. setEnabled(opened_diagram);
|
||||
m_add_item_actions_group. setEnabled(editable_project);
|
||||
m_insert_last_element-> setEnabled(opened_diagram && editable_project && !m_last_inserted_element.isNull());
|
||||
m_show_element_picker-> setEnabled(opened_diagram && editable_project);
|
||||
m_show_shortcut_bar-> setEnabled(opened_diagram && editable_project);
|
||||
m_repeat_last_command-> setEnabled(opened_diagram && editable_project
|
||||
&& m_last_command && m_last_command->isEnabled());
|
||||
m_row_column_actions_group. setEnabled(editable_project);
|
||||
m_background_color_button-> setEnabled(opened_diagram);
|
||||
m_draw_grid-> setEnabled(opened_diagram);
|
||||
m_draw_guides-> setEnabled(opened_diagram);
|
||||
m_cell_rulers-> setEnabled(opened_diagram);
|
||||
m_cell_lines-> setEnabled(opened_diagram);
|
||||
|
||||
//Project menu
|
||||
m_project_edit_properties -> setEnabled(opened_project);
|
||||
@@ -2011,6 +2253,7 @@ void QETDiagramEditor::slot_updateComplexActions()
|
||||
<< m_group_selected_texts;
|
||||
for(QAction *action : action_list)
|
||||
action->setEnabled(false);
|
||||
m_align_actions_group.setEnabled(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -2132,6 +2375,11 @@ void QETDiagramEditor::slot_updateComplexActions()
|
||||
| DiagramContent::Shapes
|
||||
| DiagramContent::Images);
|
||||
m_depth_action_group->setEnabled(list.isEmpty()? false : true);
|
||||
|
||||
//Align actions: symbols, pictures and free texts take part
|
||||
m_align_actions_group.setEnabled(!ro && (selected_elements_count
|
||||
|| selected_image
|
||||
|| dc.count(DiagramContent::TextFields)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2330,6 +2578,8 @@ void QETDiagramEditor::openBackupFiles(QList<KAutoSaveFile *> backup_files)
|
||||
//Create the project
|
||||
DialogWaiting::instance(this);
|
||||
|
||||
//QETProject takes ownership of file and deletes it, whether or not it opens
|
||||
const QString file_name = file->managedFile().fileName();
|
||||
QETProject *project = new QETProject(file, this);
|
||||
if (project->state() != QETProject::Ok)
|
||||
{
|
||||
@@ -2340,7 +2590,7 @@ void QETDiagramEditor::openBackupFiles(QList<KAutoSaveFile *> backup_files)
|
||||
tr("Échec de l'ouverture du projet", "message box title"),
|
||||
QString(tr(
|
||||
"Une erreur est survenue lors de l'ouverture du fichier %1.",
|
||||
"message box content")).arg(file->managedFile().fileName()));
|
||||
"message box content")).arg(file_name));
|
||||
}
|
||||
delete project;
|
||||
DialogWaiting::dropInstance();
|
||||
@@ -2820,6 +3070,7 @@ void QETDiagramEditor::diagramWasAdded(DiagramView *dv)
|
||||
&QETDiagramEditor::selectionChanged,
|
||||
Qt::DirectConnection);
|
||||
connect(dv, &DiagramView::modeChanged, this, &QETDiagramEditor::slot_updateModeActions);
|
||||
connect(dv, &DiagramView::elementPlacementStarted, this, &QETDiagramEditor::rememberPlacedElement);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2929,6 +3180,139 @@ void QETDiagramEditor::selectionChanged()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::insertElementFromCollection
|
||||
Place @a location on the current folio using the interactive placement
|
||||
mode -- the same mode a drag and drop ends in, entered without the drag.
|
||||
@param location
|
||||
*/
|
||||
void QETDiagramEditor::insertElementFromCollection(const ElementsLocation &location)
|
||||
{
|
||||
DiagramView *dv = currentDiagramView();
|
||||
if (dv && dv->startElementPlacement(location, dv->defaultPlacementPos())) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Nowhere to place it: no folio open, or a read-only project. Do what
|
||||
//a double click did before it placed, rather than nothing.
|
||||
m_element_collection_widget->editLocation(location);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::rememberPlacedElement
|
||||
Remember @a location for "insert last element". Connected to every view,
|
||||
so an element placed by drag and drop counts as well as one placed from
|
||||
the collection without a drag. Macros never reach here: the view does not
|
||||
report them, since re-entering their placement mode from a shortcut has
|
||||
not been thought through.
|
||||
@param location
|
||||
*/
|
||||
void QETDiagramEditor::rememberPlacedElement(const ElementsLocation &location)
|
||||
{
|
||||
m_last_inserted_element = location;
|
||||
m_insert_last_element->setEnabled(true);
|
||||
setLastCommand(m_insert_last_element);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::setLastCommand
|
||||
Remember @a action as the command Enter repeats, and name it in the
|
||||
Édition menu entry so it is clear what will happen.
|
||||
*/
|
||||
void QETDiagramEditor::setLastCommand(QAction *action)
|
||||
{
|
||||
m_last_command = action;
|
||||
m_repeat_last_command->setText(
|
||||
action == m_insert_last_element
|
||||
? tr("Répéter : insérer « %1 »").arg(m_last_inserted_element.name())
|
||||
: tr("Répéter : %1").arg(action->text().remove(QLatin1Char('&'))));
|
||||
slot_updateActions();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::repeatLastCommand
|
||||
Run the last drawing or placing command again -- a line, a text field,
|
||||
the last element -- as Enter does in SolidWorks.
|
||||
@return true if a command was run
|
||||
*/
|
||||
bool QETDiagramEditor::repeatLastCommand()
|
||||
{
|
||||
if (!m_last_command || !m_repeat_last_command->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
//A tool that is still on would be switched off by triggering it
|
||||
if (m_last_command->isCheckable() && m_last_command->isChecked()) {
|
||||
return false;
|
||||
}
|
||||
m_last_command->trigger();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::insertLastElement
|
||||
Re-enter placement mode with the element placed most recently, so a run of
|
||||
the same symbol can be dropped without returning to the collection.
|
||||
*/
|
||||
void QETDiagramEditor::insertLastElement()
|
||||
{
|
||||
if (!m_last_inserted_element.isElement()
|
||||
|| !m_last_inserted_element.exist()) {
|
||||
return;
|
||||
}
|
||||
insertElementFromCollection(m_last_inserted_element);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::elementPicker
|
||||
@return the element picker, built on first use: most sessions never open
|
||||
it, and it holds a list view and a model of its own.
|
||||
*/
|
||||
ElementPickerPopup *QETDiagramEditor::elementPicker()
|
||||
{
|
||||
if (!m_element_picker)
|
||||
{
|
||||
m_element_picker = new ElementPickerPopup(m_element_collection_widget,
|
||||
this);
|
||||
connect(m_element_picker, &ElementPickerPopup::elementChosen,
|
||||
this, &QETDiagramEditor::insertElementFromCollection);
|
||||
}
|
||||
return m_element_picker;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::showElementPicker
|
||||
Open the element picker where the mouse is.
|
||||
*/
|
||||
void QETDiagramEditor::showElementPicker()
|
||||
{
|
||||
if (!currentDiagramView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
elementPicker()->popUpAt(QCursor::pos());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::showShortcutBar
|
||||
Open the element picker where the mouse is, with a row of commands above
|
||||
it chosen by what is selected: the folio's commands with nothing
|
||||
selected, conductor commands when only conductors are, and selection
|
||||
commands otherwise. The commands are ShortcutManager ids, listed in
|
||||
ShortcutBarSettings and editable in the configuration dialog.
|
||||
*/
|
||||
void QETDiagramEditor::showShortcutBar()
|
||||
{
|
||||
DiagramView *dv = currentDiagramView();
|
||||
if (!dv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ShortcutBarSettings::Context context =
|
||||
ShortcutBarSettings::contextFor(dv->diagram()->selectedItems());
|
||||
|
||||
elementPicker()->popUpShortcutBar(QCursor::pos(), context);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::generateTerminalBlock
|
||||
*/
|
||||
@@ -3214,3 +3598,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)));
|
||||
}
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
#include <QCloseEvent>
|
||||
#include <QDir>
|
||||
#include <QMdiArea>
|
||||
#include <QPointer>
|
||||
#include <QSignalMapper>
|
||||
#include <QUndoGroup>
|
||||
|
||||
class QToolButton;
|
||||
|
||||
class QMdiSubWindow;
|
||||
class QETProject;
|
||||
class QETResult;
|
||||
@@ -43,6 +46,8 @@ class ElementsLocation;
|
||||
class RecentFiles;
|
||||
class DiagramPropertiesEditorDockWidget;
|
||||
class ElementsCollectionWidget;
|
||||
class ElementPickerPopup;
|
||||
class CommandSearchPopup;
|
||||
class AutoNumberingDockWidget;
|
||||
class TerminalNumberingDialog;
|
||||
|
||||
@@ -117,6 +122,7 @@ class QETDiagramEditor : public QETMainWindow
|
||||
void selectGroupTriggered (QAction *action);
|
||||
void addItemGroupTriggered (QAction *action);
|
||||
void selectionGroupTriggered (QAction *action);
|
||||
void alignGroupTriggered (QAction *action);
|
||||
void rowColumnGroupTriggered (QAction *action);
|
||||
void slot_updateActions();
|
||||
void slot_updateUndoStack();
|
||||
@@ -125,6 +131,12 @@ class QETDiagramEditor : public QETMainWindow
|
||||
void slot_updatePasteAction();
|
||||
void slot_updateWindowsMenu();
|
||||
void slot_updateAutoNumDock();
|
||||
void insertElementFromCollection(const ElementsLocation &location);
|
||||
void insertLastElement();
|
||||
void rememberPlacedElement(const ElementsLocation &location);
|
||||
void showElementPicker();
|
||||
void showShortcutBar();
|
||||
bool repeatLastCommand();
|
||||
void generateTerminalBlock();
|
||||
void setWindowedMode();
|
||||
void setTabbedMode();
|
||||
@@ -164,6 +176,7 @@ class QETDiagramEditor : public QETMainWindow
|
||||
void subWindowActivated(QMdiSubWindow *subWindows);
|
||||
|
||||
private slots:
|
||||
void updateTextGridButton();
|
||||
void selectionChanged();
|
||||
|
||||
public:
|
||||
@@ -171,12 +184,19 @@ class QETDiagramEditor : public QETMainWindow
|
||||
*m_edit_diagram_properties, ///< Show a dialog to edit diagram properties
|
||||
*m_conductor_reset, ///< Reset paths of selected conductors
|
||||
*m_cut, ///< Cut selection to clipboard
|
||||
*m_copy; ///< Copy selection to clipboard
|
||||
*m_copy, ///< Copy selection to clipboard
|
||||
*m_insert_last_element = nullptr; ///< Place the last placed element again
|
||||
|
||||
QActionGroup
|
||||
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_align_actions_group, ///Action related to align the selected items
|
||||
*m_depth_action_group = nullptr;
|
||||
|
||||
QMenu
|
||||
*m_add_item_menu = nullptr, ///< Submenu of m_add_item_actions_group
|
||||
*m_align_menu = nullptr, ///< Submenu of m_align_actions_group
|
||||
*m_row_column_menu = nullptr; ///< Submenu of m_row_column_actions_group
|
||||
|
||||
private:
|
||||
QActionGroup
|
||||
@@ -205,6 +225,8 @@ class QETDiagramEditor : public QETMainWindow
|
||||
*m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor
|
||||
*m_draw_grid, ///< Switch the background grid display or not
|
||||
*m_draw_guides = nullptr, ///< Switch the custom guides display or not
|
||||
*m_cell_rulers = nullptr, ///< Keep the folio column/row headers in sight or not
|
||||
*m_cell_lines = nullptr, ///< Draw the folio column/row limits across the drawing or not
|
||||
*m_project_edit_properties, ///< Edit the properties of the current project.
|
||||
*m_project_add_diagram, ///< Add a diagram to the current project.
|
||||
*m_remove_diagram_from_project, ///< Delete a diagram from the current project
|
||||
@@ -244,6 +266,8 @@ class QETDiagramEditor : public QETMainWindow
|
||||
ConductorColorToolButton *m_conductor_color_button = nullptr;
|
||||
///< Diagram background color picker, in the "Affichage" toolbar
|
||||
DiagramBgColorToolButton *m_background_color_button = nullptr;
|
||||
QMenu *m_text_grid_menu = nullptr; ///< Snap step used when dragging texts
|
||||
QToolButton *m_text_grid_button = nullptr;
|
||||
|
||||
QList <QAction *> m_zoom_action_toolBar; ///Only zoom action must displayed in the toolbar
|
||||
|
||||
@@ -258,7 +282,19 @@ class QETDiagramEditor : public QETMainWindow
|
||||
*m_qdw_elmt_collection,
|
||||
*qdw_undo; /// Dock for the undo list
|
||||
|
||||
ElementPickerPopup *elementPicker();
|
||||
QAction *m_show_element_picker = nullptr;
|
||||
QAction *m_show_shortcut_bar = nullptr;
|
||||
QAction *m_repeat_last_command = nullptr;
|
||||
/// What Enter on the folio repeats
|
||||
QPointer<QAction> m_last_command;
|
||||
void setLastCommand(QAction *action);
|
||||
ElementPickerPopup *m_element_picker = nullptr; ///< Built on first use
|
||||
QAction *m_command_search = nullptr;
|
||||
CommandSearchPopup *m_command_search_popup = nullptr; ///< Built on first use
|
||||
ElementsCollectionWidget *m_element_collection_widget;
|
||||
/// Last element placed from the collection, for "insert last"
|
||||
ElementsLocation m_last_inserted_element;
|
||||
|
||||
DiagramPropertiesEditorDockWidget *m_selection_properties_editor;
|
||||
/// Elements panel
|
||||
|
||||
@@ -185,7 +185,7 @@ void ConductorTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
|
||||
|
||||
if (parent_conductor_) {
|
||||
if (parent_conductor_->nearShape().contains(intended_pos)) {
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(intended_pos) : setPos(Diagram::snapToGrid(intended_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(intended_pos) : setPos(Diagram::snapToTextGrid(intended_pos));
|
||||
parent_conductor_ -> setHighlighted(Conductor::Normal);
|
||||
} else {
|
||||
parent_conductor_ -> setHighlighted(Conductor::Alert);
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
#include "terminal.h"
|
||||
#include "../properties/elementdata.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
//define the height of the header.
|
||||
static int header = 5;
|
||||
//define the minimal height of the cross (without header)
|
||||
@@ -194,6 +196,37 @@ QString CrossRefItem::elementPositionText(
|
||||
return txt;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::showAllConfiguredSlaves
|
||||
@param elmt : the element displaying the cross reference
|
||||
@param xrp : xref properties of that element
|
||||
@return true when the contact comb must show every slave contact the
|
||||
master defines, even those no slave is linked to yet. That is the case
|
||||
when the user asked for it, when the comb (contacts) display is the
|
||||
one in use, and when the master really declares contact groups --
|
||||
an element which declares none behaves exactly as before.
|
||||
*/
|
||||
bool CrossRefItem::showAllConfiguredSlaves(
|
||||
const Element *elmt,
|
||||
const XRefProperties &xrp)
|
||||
{
|
||||
if (!elmt) return false;
|
||||
if (!xrp.showAllConfiguredSlaves()) return false;
|
||||
if (xrp.displayHas() != XRefProperties::Contacts) return false;
|
||||
|
||||
return !elmt->elementData().m_slave_contact_groups.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::mustDrawAllConfiguredSlaves
|
||||
@return showAllConfiguredSlaves for the element of this item and the
|
||||
current properties.
|
||||
*/
|
||||
bool CrossRefItem::mustDrawAllConfiguredSlaves() const
|
||||
{
|
||||
return showAllConfiguredSlaves(m_element, m_properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::updateProperties
|
||||
update the current properties
|
||||
@@ -256,8 +289,11 @@ void CrossRefItem::updateLabel()
|
||||
QTimer::singleShot(0, this, [this]{ update(); });
|
||||
return;
|
||||
}
|
||||
//Draw cross or contact, only if master element is linked.
|
||||
else if (! m_element->linkedElements().isEmpty())
|
||||
//Draw cross or contact, if master element is linked, or if the user
|
||||
//asks for the contact comb to show the contact groups of the master
|
||||
//even before they get a slave.
|
||||
else if (! m_element->linkedElements().isEmpty()
|
||||
|| mustDrawAllConfiguredSlaves())
|
||||
{
|
||||
m_update_map = true;
|
||||
XRefProperties::DisplayHas dh = m_properties.displayHas();
|
||||
@@ -358,7 +394,8 @@ void CrossRefItem::paint(
|
||||
if (m_element->elementData().m_master_type == ElementData::PLC)
|
||||
return;
|
||||
|
||||
if (m_element->linkedElements().isEmpty()) return;
|
||||
if (m_element->linkedElements().isEmpty()
|
||||
&& !mustDrawAllConfiguredSlaves()) return;
|
||||
|
||||
QPen pen_;
|
||||
pen_.setWidthF(0.5);
|
||||
@@ -727,6 +764,41 @@ void CrossRefItem::drawAsCross(QPainter &painter)
|
||||
fillCrossRef(painter);
|
||||
}
|
||||
|
||||
namespace {
|
||||
/**
|
||||
@brief contactOption
|
||||
Map the contact group declared by a master onto the CONTACTS flags
|
||||
used by CrossRefItem::drawContact, so a group no slave is linked to
|
||||
yet is drawn like the slave it waits for.
|
||||
@param group : the contact group of the master
|
||||
@return the flags describing the contact to draw
|
||||
*/
|
||||
int contactOption(const ElementData::SlaveContactGroup &group)
|
||||
{
|
||||
int option = 0;
|
||||
|
||||
switch (group.type)
|
||||
{
|
||||
case ElementData::NO: option = CrossRefItem::NO; break;
|
||||
case ElementData::NC: option = CrossRefItem::NC; break;
|
||||
case ElementData::SW: option = CrossRefItem::SW; break;
|
||||
case ElementData::Other: option = CrossRefItem::Other; break;
|
||||
}
|
||||
|
||||
switch (group.subtype)
|
||||
{
|
||||
case ElementData::Power: option += CrossRefItem::Power; break;
|
||||
case ElementData::DelayOn: option += CrossRefItem::DelayOn; break;
|
||||
case ElementData::DelayOff: option += CrossRefItem::DelayOff; break;
|
||||
case ElementData::delayOnOff: option += CrossRefItem::DelayOnOff; break;
|
||||
case ElementData::SSimple:
|
||||
case ElementData::PLCSlave: break;
|
||||
}
|
||||
|
||||
return option;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::drawAsContacts
|
||||
Draw this crossref with symbolic contacts
|
||||
@@ -734,37 +806,99 @@ void CrossRefItem::drawAsCross(QPainter &painter)
|
||||
*/
|
||||
void CrossRefItem::drawAsContacts(QPainter &painter)
|
||||
{
|
||||
if (m_element -> isFree())
|
||||
if (m_element -> isFree() && !mustDrawAllConfiguredSlaves())
|
||||
return;
|
||||
|
||||
m_drawed_contacts = 0;
|
||||
if (m_update_map) m_hovered_contacts_map.clear();
|
||||
QRectF bounding_rect;
|
||||
|
||||
//Draw each linked contact
|
||||
foreach (Element *elmt, m_element->linkedElements())
|
||||
//Draw every contact group of the master, in the order the master
|
||||
//defines them, when the user asked for it and the master declares
|
||||
//contact groups. Otherwise the comb keeps its historical behavior:
|
||||
//linked slaves only, in position order.
|
||||
if (mustDrawAllConfiguredSlaves())
|
||||
{
|
||||
DiagramContext info = elmt->kindInformations();
|
||||
const QVector<ElementData::SlaveContactGroup> groups =
|
||||
m_element->elementData().m_slave_contact_groups;
|
||||
|
||||
for (int i=0; i<info["number"].toInt(); i++)
|
||||
//A contact group waits for exactly one slave: a slave assigned to
|
||||
//a group is drawn where the master puts it, whatever its position
|
||||
//on the diagram.
|
||||
QHash<int, Element *> slotted;
|
||||
QList<Element *> unassigned;
|
||||
foreach (Element *elmt, m_element->linkedElements()) //position order
|
||||
{
|
||||
int option = 0;
|
||||
|
||||
QString state = info["state"].toString();
|
||||
if (state == "NO") option = NO;
|
||||
else if (state == "NC") option = NC;
|
||||
else if (state == "SW") option = SW;
|
||||
else if (state == "Other") option = Other;
|
||||
|
||||
QString type = info["type"].toString();
|
||||
if (type == "power") option += Power;
|
||||
else if (type == "delayOn") option += DelayOn;
|
||||
else if (type == "delayOff") option += DelayOff;
|
||||
else if (type == "delayOnOff") option += DelayOnOff;
|
||||
|
||||
QRectF br = drawContact(painter, option, elmt, i);
|
||||
bounding_rect = bounding_rect.united(br);
|
||||
const int index = m_element->groupIndexForElement(elmt);
|
||||
if (index >= 0 && index < groups.size() && !slotted.contains(index))
|
||||
slotted.insert(index, elmt);
|
||||
else
|
||||
unassigned << elmt;
|
||||
}
|
||||
|
||||
for (int i = 0; i < groups.size(); ++i)
|
||||
{
|
||||
if (Element *slave = slotted.value(i, nullptr))
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawLinkedSlaveContacts(painter, slave));
|
||||
else
|
||||
{
|
||||
//No slave is linked to this group yet: the contact
|
||||
//symbol of the group is drawn with the terminal names
|
||||
//the master defines for it, there is no cross reference
|
||||
//to show for it.
|
||||
const int option = contactOption(groups.at(i));
|
||||
const int poles = qMax(1, groups.at(i).contactCount);
|
||||
QStringList labels = groups.at(i).labels;
|
||||
|
||||
//A single pole simple contact (NO or NC) reads its two
|
||||
//numbers the other way round (checked against the
|
||||
//diagram). Changeover contacts are not handled here:
|
||||
//their labels are mapped to the right contact half in
|
||||
//drawContact(), per pole, so multi pole changeovers work
|
||||
//too. Groups with several NO/NC poles keep the order the
|
||||
//master defines: a 3 pole power contact already reads
|
||||
//correctly that way.
|
||||
if (poles == 1 && (option & NOC))
|
||||
std::reverse(labels.begin(), labels.end());
|
||||
|
||||
//The declared terminals are distributed over the declared
|
||||
//poles. terminalCount and contactCount are edited
|
||||
//independently in the element editor, so the list can be
|
||||
//shorter than two entries per pole (three for a switch):
|
||||
//every pole then gets its share of what exists, instead
|
||||
//of a fixed 2/3 stride starving all but the first poles.
|
||||
const int per_pole = labels.size() / poles;
|
||||
const int extra = labels.size() % poles;
|
||||
int begin = 0;
|
||||
for (int pole = 0; pole < poles; ++pole)
|
||||
{
|
||||
const int count = per_pole + (pole < extra ? 1 : 0);
|
||||
const QStringList pole_labels = labels.mid(begin, count);
|
||||
begin += count;
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawContact(painter,
|
||||
option,
|
||||
nullptr,
|
||||
pole,
|
||||
pole_labels));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Slaves the master doesn't assign to one of its groups (a link
|
||||
//made before the master declared groups, for example) keep their
|
||||
//usual place: the end of the comb, in position order.
|
||||
foreach (Element *elmt, unassigned)
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawLinkedSlaveContacts(painter, elmt));
|
||||
}
|
||||
else
|
||||
{
|
||||
//Draw each linked contact, in position order
|
||||
foreach (Element *elmt, m_element->linkedElements())
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawLinkedSlaveContacts(painter, elmt));
|
||||
}
|
||||
|
||||
bounding_rect.adjust(-30, -4, 4, 4);
|
||||
@@ -773,17 +907,61 @@ void CrossRefItem::drawAsContacts(QPainter &painter)
|
||||
m_shape_path.addRect(bounding_rect);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::drawLinkedSlaveContacts
|
||||
Draw the contact symbols of one slave linked to this master.
|
||||
@param painter : painter to use
|
||||
@param elmt : the slave element to draw
|
||||
@return the bounding rect of the draw
|
||||
*/
|
||||
QRectF CrossRefItem::drawLinkedSlaveContacts(QPainter &painter, Element *elmt)
|
||||
{
|
||||
QRectF bounding_rect;
|
||||
DiagramContext info = elmt->kindInformations();
|
||||
|
||||
for (int i=0; i<info["number"].toInt(); i++)
|
||||
{
|
||||
int option = 0;
|
||||
|
||||
QString state = info["state"].toString();
|
||||
if (state == "NO") option = NO;
|
||||
else if (state == "NC") option = NC;
|
||||
else if (state == "SW") option = SW;
|
||||
else if (state == "Other") option = Other;
|
||||
|
||||
QString type = info["type"].toString();
|
||||
if (type == "power") option += Power;
|
||||
else if (type == "delayOn") option += DelayOn;
|
||||
else if (type == "delayOff") option += DelayOff;
|
||||
else if (type == "delayOnOff") option += DelayOnOff;
|
||||
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawContact(painter, option, elmt, i));
|
||||
}
|
||||
|
||||
return bounding_rect;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::drawContact
|
||||
Draw one contact, the type of contact to draw is define in flags.
|
||||
@param painter : painter to use
|
||||
@param flags : define how to draw the contact (see enul CONTACTS)
|
||||
@param elmt : the element to display text (the position of the contact)
|
||||
@param elmt : the element to display text (the position of the contact).
|
||||
It may be nullptr when the contact comes from a contact group the master
|
||||
defines but no slave is linked to yet: no position text, no hover/click
|
||||
support, and the terminal names then come from master_labels.
|
||||
@param pole_index : which contact of the group is drawn (0 based), used
|
||||
to pick the right pair of terminal names of a linked multi-pole contact.
|
||||
@param master_labels : the terminal names the master declares for this
|
||||
pole (sliced from ElementData::SlaveContactGroup::labels by the caller),
|
||||
used when elmt is nullptr so an empty slot shows the numbers the master
|
||||
declares, the same way a linked slave would show them.
|
||||
@return The bounding rect of the draw (contact + text)
|
||||
*/
|
||||
QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, int pole_index)
|
||||
QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, int pole_index, const QStringList &master_labels)
|
||||
{
|
||||
QString str = elementPositionText(elmt);
|
||||
QString str = elmt ? elementPositionText(elmt) : QString();
|
||||
|
||||
// Collect terminal names from the element definition (.elmt)
|
||||
// e.g. name="13" and name="14" on each terminal
|
||||
@@ -791,12 +969,12 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
// For SW contacts with typed terminals (No/Nc/Common), filter by role.
|
||||
QStringList terminal_names;
|
||||
const bool is_power_ctc =
|
||||
elmt->kindInformations()["type"].toString() == "power";
|
||||
elmt && elmt->kindInformations()["type"].toString() == "power";
|
||||
const bool is_sw = (flags & SW) && !(flags & NOC);
|
||||
|
||||
// Check if SW terminals have explicit No/Nc/Common types
|
||||
bool sw_has_typed_terminals = false;
|
||||
if (is_sw) {
|
||||
if (is_sw && elmt) {
|
||||
for (Terminal *t : elmt->terminals()) {
|
||||
if (!t) continue;
|
||||
if (t->terminalType() == TerminalData::No ||
|
||||
@@ -808,11 +986,34 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
}
|
||||
}
|
||||
|
||||
for (Terminal *t : elmt->terminals()) {
|
||||
if (!t) continue;
|
||||
const QString tname = t->name();
|
||||
if (!tname.isEmpty())
|
||||
terminal_names << tname;
|
||||
if (elmt) {
|
||||
for (Terminal *t : elmt->terminals()) {
|
||||
if (!t) continue;
|
||||
const QString tname = t->name();
|
||||
if (!tname.isEmpty())
|
||||
terminal_names << tname;
|
||||
}
|
||||
} else if (!master_labels.isEmpty()) {
|
||||
//Empty slot of the contact comb: the slave is missing but the
|
||||
//master already declares the terminal names, so the slot shows
|
||||
//them instead of staying mute. master_labels contains exactly
|
||||
//the terminals of this pole (the caller slices the declared
|
||||
//terminal list over the declared poles), in the order a linked
|
||||
//slave would receive them.
|
||||
if (is_sw) {
|
||||
//The labels are stored in terminal order (for a typical
|
||||
//changeover contact: common, NC, NO, i.e. 11, 12, 14), while
|
||||
//the symbol draws NC bottom-left, NO top-left and the common
|
||||
//on the right: every stored entry goes to its own position.
|
||||
//Entries the master doesn't declare (terminal count lower
|
||||
//than three) simply stay empty instead of landing on the
|
||||
//wrong contact half like the raw stored order would.
|
||||
terminal_names << master_labels.value(1)
|
||||
<< master_labels.value(2)
|
||||
<< master_labels.value(0);
|
||||
} else {
|
||||
terminal_names = master_labels;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_power_ctc) {
|
||||
@@ -860,7 +1061,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
QRectF bounding_rect = QRectF(0, offset, 24, 10);
|
||||
|
||||
QPen pen = painter.pen();
|
||||
m_hovered_contact == elmt ? pen.setColor(Qt::blue) :pen.setColor(Qt::black);
|
||||
elmt && m_hovered_contact == elmt ? pen.setColor(Qt::blue) :pen.setColor(Qt::black);
|
||||
painter.setPen(pen);
|
||||
|
||||
//Draw NO or NC contact
|
||||
@@ -956,11 +1157,18 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
}
|
||||
}
|
||||
|
||||
//The hit rect is registered even when the position text is
|
||||
//empty: a linked contact had (and keeps) its hover/click entry
|
||||
//in that case too, only the drawing is skipped. Free slots
|
||||
//(elmt == nullptr) have nothing to click and stay out of the map.
|
||||
QRectF text_rect = painter.boundingRect(QRectF(30, offset, 5, 10), Qt::AlignLeft | Qt::AlignVCenter, str);
|
||||
painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
if (!str.isEmpty())
|
||||
{
|
||||
painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
}
|
||||
|
||||
if (m_update_map)
|
||||
if (m_update_map && elmt)
|
||||
m_hovered_contacts_map.insert(elmt, text_rect);
|
||||
|
||||
++m_drawed_contacts;
|
||||
@@ -1038,12 +1246,16 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
QRectF(30, offset+4, 5, 10),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
|
||||
if (m_update_map)
|
||||
if (!str.isEmpty())
|
||||
{
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
}
|
||||
//Hit rect kept even for an empty position text (as before),
|
||||
//free slots are not clickable.
|
||||
if (m_update_map && elmt)
|
||||
m_hovered_contacts_map.insert(elmt, text_rect);
|
||||
|
||||
//a switch contact take place of two normal contact
|
||||
@@ -1067,15 +1279,19 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
|
||||
//Draw position text
|
||||
QRectF text_rect = painter.boundingRect(
|
||||
QRectF(30, offset, 5, 10),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
|
||||
if (m_update_map)
|
||||
QRectF(30, offset, 5, 10),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
if (!str.isEmpty())
|
||||
{
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
}
|
||||
//Hit rect kept even for an empty position text (as before),
|
||||
//free slots are not clickable.
|
||||
if (m_update_map && elmt)
|
||||
m_hovered_contacts_map.insert(elmt, text_rect);
|
||||
++m_drawed_contacts;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ class CrossRefItem : public QGraphicsObject
|
||||
enum { Type = UserType + 1009 };
|
||||
int type() const override { return Type; }
|
||||
|
||||
/// Returns true when \a xrp asks the contact comb of \a elmt to show
|
||||
/// every slave contact the master defines, even the ones no slave is
|
||||
/// linked to yet. \a elmt must be a master element.
|
||||
static bool showAllConfiguredSlaves(
|
||||
const Element *elmt,
|
||||
const XRefProperties &xrp);
|
||||
|
||||
/**
|
||||
@brief The CONTACTS enum
|
||||
*/
|
||||
@@ -121,7 +128,13 @@ class CrossRefItem : public QGraphicsObject
|
||||
void drawAsCross(QPainter &painter);
|
||||
void drawAsContacts(QPainter &painter);
|
||||
void drawAsPlcTable(QPainter &painter);
|
||||
QRectF drawContact(QPainter &painter, int flags, Element *elmt, int pole_index = 0);
|
||||
bool mustDrawAllConfiguredSlaves() const;
|
||||
QRectF drawLinkedSlaveContacts(QPainter &painter, Element *elmt);
|
||||
QRectF drawContact(QPainter &painter,
|
||||
int flags,
|
||||
Element *elmt,
|
||||
int pole_index = 0,
|
||||
const QStringList &master_labels = QStringList());
|
||||
void fillCrossRef(QPainter &painter);
|
||||
void AddExtraInfo(QPainter &painter, const QString&);
|
||||
QList<Element *> NOElements() const;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#include <QBuffer>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFontMetricsF>
|
||||
#include <QGraphicsSceneContextMenuEvent>
|
||||
#include <QImageWriter>
|
||||
#include <QMenu>
|
||||
@@ -69,7 +70,7 @@ DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *paren
|
||||
// those are a single uniform scale() float, which is exactly why an
|
||||
// image could never break its own aspect ratio before this class
|
||||
// gained proper independent scaleX/scaleY.
|
||||
m_transform.pivot = boundingRect().center();
|
||||
m_transform.pivot = imageRect().center();
|
||||
setTransform(m_transform.toMatrix());
|
||||
setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges);
|
||||
setAcceptHoverEvents(true);
|
||||
@@ -110,6 +111,45 @@ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *
|
||||
painter -> drawRect(boundingRect());
|
||||
painter -> restore();
|
||||
}
|
||||
|
||||
if (!m_label.isEmpty())
|
||||
{
|
||||
// Undo the picture's own scale so the caption keeps the size of
|
||||
// every other text on the folio whether the picture is shown at
|
||||
// 5 % or 500 %. Rotation and skew are applied after scale in
|
||||
// ShapeTransform::toMatrix(), so the caption still turns with it.
|
||||
const QRectF r = labelRect();
|
||||
painter -> save();
|
||||
painter -> translate(r.topLeft());
|
||||
painter -> scale(1.0 / m_label_scale.x(), 1.0 / m_label_scale.y());
|
||||
painter -> setFont(m_label_font);
|
||||
painter -> setPen(Qt::black);
|
||||
painter -> drawText(QRectF(QPointF(0, 0), m_label_size), Qt::AlignCenter, m_label);
|
||||
painter -> restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::setLabel
|
||||
Set the caption drawn under the picture. An empty string removes it.
|
||||
@param label
|
||||
*/
|
||||
void DiagramImageItem::setLabel(const QString &label)
|
||||
{
|
||||
if (label == m_label)
|
||||
return;
|
||||
|
||||
prepareGeometryChange();
|
||||
m_label = label;
|
||||
// Font looked up here, not in paint(): diagramTextsFont() reads
|
||||
// QSettings, far too slow to do on every repaint.
|
||||
if (!m_label.isEmpty())
|
||||
m_label_font = QETApp::diagramTextsFont();
|
||||
m_label_size = m_label.isEmpty()
|
||||
? QSizeF()
|
||||
: QFontMetricsF(m_label_font).size(0, m_label);
|
||||
updateLabelScale();
|
||||
emit labelChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,7 +318,7 @@ void DiagramImageItem::setPivotRaw(const QPointF &newPivot)
|
||||
void DiagramImageItem::resetPivotToBoundingRectCenter()
|
||||
{
|
||||
m_pivotIsCustom = false;
|
||||
setPivot(boundingRect().center());
|
||||
setPivot(imageRect().center());
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -890,7 +930,17 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
*/
|
||||
void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods)
|
||||
{
|
||||
const QPointF pivotScene = pos() + m_transform.pivot;
|
||||
// mapToScene(), not the pos() + m_transform.pivot shortcut: that
|
||||
// shortcut only holds because the pivot is a fixed point of
|
||||
// m_transform.toMatrix() -- true exactly as long as m_transform is
|
||||
// the item's ONLY transform. It silently breaks the moment anything
|
||||
// else is composed on top (QGraphicsItem's own scale()/rotation(),
|
||||
// or a future parent-group transform), giving a scenePivot that
|
||||
// doesn't match where the pivot handle is actually drawn (rebuildHandles()
|
||||
// already uses mapToScene() for that, which is why the handle looks
|
||||
// right even when a drag computed from this shortcut doesn't).
|
||||
// mapToScene() is correct regardless of what else is composed in.
|
||||
const QPointF pivotScene = mapToScene(m_transform.pivot);
|
||||
QTransform undoRotation;
|
||||
undoRotation.rotate(-m_transform.rotation);
|
||||
const QPointF postScaleOffset = undoRotation.map(mapToScene(localPos) - pivotScene);
|
||||
@@ -961,7 +1011,9 @@ void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::Keyboa
|
||||
*/
|
||||
void DiagramImageItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
|
||||
{
|
||||
const QPointF scenePivot = pos() + m_transform.pivot;
|
||||
// mapToScene(), not pos() + m_transform.pivot: see dragResize()'s
|
||||
// identical comment for why.
|
||||
const QPointF scenePivot = mapToScene(m_transform.pivot);
|
||||
const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x()));
|
||||
|
||||
const QPointF reference = scaleAndShearOffset(cornerPosition(cornerIndex, pixmap_.width(), pixmap_.height()));
|
||||
@@ -995,7 +1047,9 @@ void DiagramImageItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt
|
||||
|
||||
const qreal rad = qDegreesToRadians(m_transform.rotation);
|
||||
const qreal c = qCos(rad), s = qSin(rad);
|
||||
const QPointF targetRel = scenePos - pos() - pivot;
|
||||
// mapToScene(pivot), not pos() + pivot: see dragResize()'s identical
|
||||
// comment for why.
|
||||
const QPointF targetRel = scenePos - mapToScene(pivot);
|
||||
const QPointF M(targetRel.x() * c + targetRel.y() * s,
|
||||
-targetRel.x() * s + targetRel.y() * c);
|
||||
|
||||
@@ -1249,6 +1303,11 @@ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant
|
||||
}
|
||||
refreshInteractionHints();
|
||||
}
|
||||
else if (change == ItemTransformChange && !m_label.isEmpty())
|
||||
{
|
||||
prepareGeometryChange();
|
||||
updateLabelScale();
|
||||
}
|
||||
else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged)
|
||||
{
|
||||
if (!m_deferHandleReposition)
|
||||
@@ -1294,6 +1353,18 @@ QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect
|
||||
@return a QRectF represent the bounding rectangle
|
||||
*/
|
||||
QRectF DiagramImageItem::boundingRect() const
|
||||
{
|
||||
if (m_label.isEmpty())
|
||||
return imageRect();
|
||||
return imageRect().united(labelRect());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::imageRect
|
||||
@return the picture's own rectangle, without its label. Handles, the
|
||||
pivot and every other piece of geometry work on this one.
|
||||
*/
|
||||
QRectF DiagramImageItem::imageRect() const
|
||||
{
|
||||
if (!pixmap_.isNull()) {
|
||||
return (QRectF(pixmap_.rect()));
|
||||
@@ -1303,6 +1374,34 @@ QRectF DiagramImageItem::boundingRect() const
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::labelRect
|
||||
@return the label's rectangle in item coordinates, centred just under
|
||||
the picture. Divided by the picture's scale, because paint() draws
|
||||
the label unscaled.
|
||||
*/
|
||||
QRectF DiagramImageItem::labelRect() const
|
||||
{
|
||||
const QRectF image = imageRect();
|
||||
const qreal w = m_label_size.width() / m_label_scale.x();
|
||||
const qreal h = m_label_size.height() / m_label_scale.y();
|
||||
const qreal gap = 2.0 / m_label_scale.y();
|
||||
return QRectF(image.center().x() - w / 2.0, image.bottom() + gap, w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::updateLabelScale
|
||||
The label rect depends on the picture's scale, so boundingRect()
|
||||
changes with it. m_transform is already updated by the time the item
|
||||
transform changes, so the scale is cached here and only refreshed
|
||||
after prepareGeometryChange() has recorded the old rect.
|
||||
*/
|
||||
void DiagramImageItem::updateLabelScale()
|
||||
{
|
||||
const auto safe = [](qreal v) { return qFuzzyIsNull(v) ? 1.0 : qAbs(v); };
|
||||
m_label_scale = QPointF(safe(m_transform.scaleX), safe(m_transform.scaleY));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::name
|
||||
@return the generic name of this item (picture)
|
||||
@@ -1312,6 +1411,36 @@ QString DiagramImageItem::name() const
|
||||
return tr("une image");
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Only PNG bytes read from a file are reused as they are: toXml()
|
||||
// has always written PNG, and a hand-edited file holding another
|
||||
// format keeps being converted, exactly as before.
|
||||
bool isPng(const QByteArray &data)
|
||||
{
|
||||
return data.startsWith("\x89PNG\r\n\x1a\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::encodedPng
|
||||
@return pixmap as PNG bytes, encoded only when cache does not already
|
||||
hold them. QPixmap::cacheKey() changes whenever the pixmap is replaced
|
||||
or modified, which is what makes this safe without every function
|
||||
that edits the picture having to remember to invalidate the cache.
|
||||
*/
|
||||
const QByteArray &DiagramImageItem::encodedPng(const QPixmap &pixmap, QByteArray &cache, qint64 &cacheKey)
|
||||
{
|
||||
if (cacheKey != pixmap.cacheKey())
|
||||
{
|
||||
cache.clear();
|
||||
QBuffer buffer(&cache);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
pixmap.save(&buffer, "PNG");
|
||||
cacheKey = pixmap.cacheKey();
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::fromXml
|
||||
Load this image from xml element e
|
||||
@@ -1323,7 +1452,10 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
|
||||
if (e.tagName() != "image") {
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
const QUuid uuid(e.attribute(QStringLiteral("uuid")));
|
||||
if (!uuid.isNull() && uuid != m_uuid) setUuid(uuid);
|
||||
|
||||
QDomNode image_node = e.firstChild();
|
||||
if (!image_node.isText()) {
|
||||
return (false);
|
||||
@@ -1337,6 +1469,17 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
|
||||
QPixmap pixmap;
|
||||
pixmap.loadFromData(array);
|
||||
setPixmap(pixmap);
|
||||
// From the first text node only, not e.text(): that also collects the
|
||||
// text of every child element, so on a cropped or colour-keyed picture
|
||||
// it carries <image_base>'s bytes too. loadFromData() stops at the end
|
||||
// of the first PNG and never notices; a cache written back verbatim
|
||||
// would.
|
||||
const QByteArray ownArray = QByteArray::fromBase64(image_node.toText().data().toLatin1());
|
||||
if (isPng(ownArray) && !pixmap_.isNull())
|
||||
{
|
||||
m_png_cache = ownArray;
|
||||
m_png_cache_key = pixmap_.cacheKey();
|
||||
}
|
||||
|
||||
// Falls back to treating the loaded result as its own base, with no
|
||||
// remembered crop or colours -- correct both for a genuinely plain
|
||||
@@ -1390,7 +1533,14 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
|
||||
const QByteArray baseArray = QByteArray::fromBase64(baseElement.text().toLatin1());
|
||||
QPixmap basePixmap;
|
||||
if (basePixmap.loadFromData(baseArray))
|
||||
{
|
||||
m_base_pixmap = basePixmap;
|
||||
if (isPng(baseArray))
|
||||
{
|
||||
m_base_png_cache = baseArray;
|
||||
m_base_png_cache_key = m_base_pixmap.cacheKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
// m_crop_rect may still refer to a saved file's base image, not
|
||||
// pixmap (used as a fallback above only when nothing better is
|
||||
@@ -1412,7 +1562,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
|
||||
m_transform.rotation = e.attribute("rotation").toDouble();
|
||||
m_transform.scaleX = e.attribute("size").toDouble();
|
||||
m_transform.scaleY = m_transform.scaleX;
|
||||
m_transform.pivot = boundingRect().center();
|
||||
m_transform.pivot = imageRect().center();
|
||||
m_pivotIsCustom = false;
|
||||
|
||||
const QDomElement transformElement = e.firstChildElement("transform");
|
||||
@@ -1433,6 +1583,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
|
||||
QGraphicsObject::setPos(e.attribute("x").toDouble(), e.attribute("y").toDouble());
|
||||
setZValue(e.attribute("z", QString::number(this->zValue())).toDouble());
|
||||
is_movable_ = (e.attribute("is_movable").toInt());
|
||||
setLabel(e.attribute("label"));
|
||||
|
||||
return (true);
|
||||
}
|
||||
@@ -1444,6 +1595,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
|
||||
QDomElement DiagramImageItem::toXml(QDomDocument &document) const
|
||||
{
|
||||
QDomElement result = document.createElement("image");
|
||||
result.setAttribute("uuid", m_uuid.toString());
|
||||
//write some attribute
|
||||
result.setAttribute("x", QString::number(pos().x()));
|
||||
result.setAttribute("y", QString::number(pos().y()));
|
||||
@@ -1463,12 +1615,13 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const
|
||||
result.setAttribute("rotation", QString::number(QET::correctAngle(m_transform.rotation)));
|
||||
result.setAttribute("size", QString::number(m_transform.scaleX));
|
||||
result.setAttribute("is_movable", bool(is_movable_));
|
||||
// An attribute, not a child element: fromXml() of every earlier
|
||||
// version requires the base64 text to be the first child.
|
||||
if (!m_label.isEmpty())
|
||||
result.setAttribute("label", m_label);
|
||||
|
||||
//write the pixmap in the xml element after he was been transformed to base64
|
||||
QByteArray array;
|
||||
QBuffer buffer(&array);
|
||||
buffer.open(QIODevice::ReadWrite);
|
||||
pixmap_.save(&buffer, "PNG");
|
||||
const QByteArray &array = encodedPng(pixmap_, m_png_cache, m_png_cache_key);
|
||||
QDomText base64 = document.createTextNode(array.toBase64());
|
||||
result.appendChild(base64);
|
||||
|
||||
@@ -1538,10 +1691,7 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const
|
||||
|
||||
if (hasCrop || hasColors)
|
||||
{
|
||||
QByteArray baseArray;
|
||||
QBuffer baseBuffer(&baseArray);
|
||||
baseBuffer.open(QIODevice::ReadWrite);
|
||||
m_base_pixmap.save(&baseBuffer, "PNG");
|
||||
const QByteArray &baseArray = encodedPng(m_base_pixmap, m_base_png_cache, m_base_png_cache_key);
|
||||
QDomElement baseElement = document.createElement("image_base");
|
||||
baseElement.appendChild(document.createTextNode(baseArray.toBase64()));
|
||||
result.appendChild(baseElement);
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
#include "../ui/imagetransparentcolordialog.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QList>
|
||||
#include <QUuid>
|
||||
#include <QStyleOptionGraphicsItem>
|
||||
#include <QVector>
|
||||
|
||||
@@ -46,6 +48,7 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
Q_PROPERTY(qreal skewX READ skewX WRITE setSkewX NOTIFY transformChanged)
|
||||
Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged)
|
||||
Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged)
|
||||
Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged)
|
||||
// A second, deliberately non-compensating property on the SAME
|
||||
// underlying value -- setPivot() (above) intentionally adjusts
|
||||
// pos() to keep the image visually in place, which is exactly
|
||||
@@ -91,6 +94,9 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
|
||||
virtual bool fromXml(const QDomElement &);
|
||||
virtual QDomElement toXml(QDomDocument &) const;
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setUuid(const QUuid &uuid) {m_uuid = uuid; emit uuidChanged();}
|
||||
void newUuid() {setUuid(QUuid::createUuid());} //create new uuid for this item
|
||||
void editProperty() override;
|
||||
void setPixmap(const QPixmap &pixmap);
|
||||
QPixmap pixmap() const { return pixmap_; }
|
||||
@@ -123,10 +129,14 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
QPointF pivot() const { return m_transform.pivot; }
|
||||
void setPivot(const QPointF &pivot);
|
||||
void setPivotRaw(const QPointF &pivot);
|
||||
QString label() const { return m_label; }
|
||||
void setLabel(const QString &label);
|
||||
|
||||
signals:
|
||||
void pixmapChanged();
|
||||
void transformChanged();
|
||||
void uuidChanged();
|
||||
void labelChanged();
|
||||
|
||||
protected:
|
||||
void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override;
|
||||
@@ -147,6 +157,7 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
void saveOriginalImageAs();
|
||||
void saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency);
|
||||
static bool writeRasterAsSvg(const QPixmap &pixmap, const QString &path);
|
||||
static const QByteArray &encodedPng(const QPixmap &pixmap, QByteArray &cache, qint64 &cacheKey);
|
||||
static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<ImageTransparentColorDialog::PickedColor> &colors);
|
||||
|
||||
void toggleHandleMode();
|
||||
@@ -175,8 +186,12 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
static QString hintForHandleRole(HandleRole role);
|
||||
void showStatusHint(const QString &text) const;
|
||||
void clearStatusHint() const;
|
||||
QRectF imageRect() const;
|
||||
QRectF labelRect() const;
|
||||
void updateLabelScale();
|
||||
|
||||
protected:
|
||||
QUuid m_uuid = QUuid::createUuid();
|
||||
QPixmap pixmap_;
|
||||
// The true, pristine original -- never itself cropped or colour-
|
||||
// keyed. pixmap_ (the displayed result) is always re-derived from
|
||||
@@ -196,6 +211,16 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
QPixmap m_base_pixmap;
|
||||
QRect m_crop_rect; // relative to m_base_pixmap; equals m_base_pixmap.rect() when nothing has been cropped
|
||||
QList<ImageTransparentColorDialog::PickedColor> m_transparent_colors;
|
||||
// PNG bytes of pixmap_ and m_base_pixmap as last written, reused by
|
||||
// toXml() while the pixmap's cacheKey() still matches. PNG encoding
|
||||
// is the bulk of the time a save spends on a picture, and redoing it
|
||||
// on every save, autosave and copy of an unchanged picture is pure
|
||||
// waste. Filled from the file itself on load, so even the first save
|
||||
// encodes nothing.
|
||||
mutable QByteArray m_png_cache;
|
||||
mutable qint64 m_png_cache_key = 0;
|
||||
mutable QByteArray m_base_png_cache;
|
||||
mutable qint64 m_base_png_cache_key = 0;
|
||||
|
||||
// Independent scaleX/scaleY here is the actual point of this whole
|
||||
// member: QGraphicsItem::scale() is a single, uniform float, which
|
||||
@@ -212,6 +237,13 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
QPointF m_original_pos; // scene position at the start of a resize/rotate/pivot drag, for Escape-to-cancel
|
||||
ShapeTransform m_original_transform;
|
||||
bool m_deferHandleReposition = false; // see setPivot()'s comment
|
||||
// Optional caption drawn centred under the picture (issue #349).
|
||||
// Empty by default, and then neither saved nor painted, so a picture
|
||||
// without one costs exactly what it did before.
|
||||
QString m_label;
|
||||
QFont m_label_font;
|
||||
QSizeF m_label_size; // in scene units, measured once in setLabel()
|
||||
QPointF m_label_scale{1.0, 1.0}; // scale the label rect was last computed for -- see updateLabelScale()
|
||||
bool m_resizeCenterAnchored = false; // decided once, at press time -- see handlerMousePressEvent()'s comment for why, mirroring the identical fix already made for shape creation
|
||||
};
|
||||
#endif
|
||||
|
||||
@@ -370,7 +370,7 @@ void DiagramTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
|
||||
|
||||
//Set the actual pos
|
||||
QPointF new_pos = event->scenePos() + m_mouse_to_origin_movement;
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToTextGrid(new_pos));
|
||||
|
||||
|
||||
//Update the actual movement for other selected item
|
||||
|
||||
@@ -639,7 +639,7 @@ void DynamicElementTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
//DiagramTextItem::mouseMoveEvent() for independent texts.
|
||||
//Without it this was the only text move in the editor that
|
||||
//ignored the grid.
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToTextGrid(new_pos));
|
||||
|
||||
if(diagram())
|
||||
diagram()->elementTextsMover().continueMovement(event);
|
||||
@@ -810,8 +810,12 @@ QVariant DynamicElementTextItem::itemChange(QGraphicsItem::GraphicsItemChange ch
|
||||
connect(m_parent_element.data(), &Element::linkedElementChanged, this, &DynamicElementTextItem::updateXref);
|
||||
if(m_parent_element.data()->diagram())
|
||||
connect(m_parent_element.data()->diagram()->project(), &QETProject::XRefPropertiesChanged, this, &DynamicElementTextItem::updateXref);
|
||||
if(!m_parent_element.data()->linkedElements().isEmpty())
|
||||
updateXref();
|
||||
//Also call updateXref for a master without any linked slave:
|
||||
//when the contact comb must show every contact group the master
|
||||
//defines, the cross ref is expected the moment the element lands
|
||||
//on the diagram, not only after the first link or the first
|
||||
//settings change.
|
||||
updateXref();
|
||||
}
|
||||
|
||||
m_first_scene_change = false;
|
||||
@@ -1582,8 +1586,10 @@ void DynamicElementTextItem::updateXref()
|
||||
|
||||
if(m_text_from == DynamicElementTextItem::ElementInfo &&
|
||||
m_info_name == "label" &&
|
||||
!m_parent_element.data()->linkedElements().isEmpty() &&
|
||||
xrp.snapTo() == XRefProperties::Label)
|
||||
xrp.snapTo() == XRefProperties::Label &&
|
||||
(!m_parent_element.data()->linkedElements().isEmpty()
|
||||
|| CrossRefItem::showAllConfiguredSlaves(
|
||||
m_parent_element.data(), xrp)))
|
||||
{
|
||||
//For add a Xref, this text must not be in a group
|
||||
if(!parentGroup())
|
||||
|
||||
@@ -60,12 +60,48 @@ ElementTextItemGroup::ElementTextItemGroup(const QString &name,
|
||||
this,
|
||||
&ElementTextItemGroup::updateXref);
|
||||
if(parent->diagram())
|
||||
connect(parent->diagram()->project(),
|
||||
m_project_xref_connection = connect(
|
||||
parent->diagram()->project(),
|
||||
&QETProject::XRefPropertiesChanged,
|
||||
this,
|
||||
&ElementTextItemGroup::updateXref);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementTextItemGroup::itemChange
|
||||
The group is very often built while its element is not on a scene yet
|
||||
(project or element loading): the connection to the project was then
|
||||
impossible and the first updateXref() ran without a diagram, so the
|
||||
cross ref of a master waiting for its slaves stayed invisible until an
|
||||
unrelated settings change happened to refresh it. Do both here, the
|
||||
moment the group really reaches the scene.
|
||||
@param change
|
||||
@param value
|
||||
@return
|
||||
*/
|
||||
QVariant ElementTextItemGroup::itemChange(
|
||||
QGraphicsItem::GraphicsItemChange change,
|
||||
const QVariant &value)
|
||||
{
|
||||
if (change == QGraphicsItem::ItemSceneHasChanged)
|
||||
{
|
||||
if (m_parent_element
|
||||
&& m_parent_element->diagram()
|
||||
&& m_parent_element->diagram()->project())
|
||||
{
|
||||
QETProject *project = m_parent_element->diagram()->project();
|
||||
if (!m_project_xref_connection)
|
||||
m_project_xref_connection = connect(
|
||||
project,
|
||||
&QETProject::XRefPropertiesChanged,
|
||||
this,
|
||||
&ElementTextItemGroup::updateXref);
|
||||
updateXref();
|
||||
}
|
||||
}
|
||||
return QGraphicsItemGroup::itemChange(change, value);
|
||||
}
|
||||
|
||||
ElementTextItemGroup::~ElementTextItemGroup()
|
||||
{}
|
||||
|
||||
@@ -652,7 +688,7 @@ void ElementTextItemGroup::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
button_down_parent_pos = mapToParent(mapFromScene(event->buttonDownScenePos(Qt::LeftButton)));
|
||||
|
||||
QPointF new_pos = m_initial_position + current_parent_pos - button_down_parent_pos;
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToTextGrid(new_pos));
|
||||
|
||||
if(diagram())
|
||||
diagram()->elementTextsMover().continueMovement(event);
|
||||
@@ -772,13 +808,13 @@ void ElementTextItemGroup::updateXref()
|
||||
{
|
||||
QETProject *project = m_parent_element->diagram()->project();
|
||||
|
||||
if(m_parent_element->linkType() == Element::Master &&
|
||||
!m_parent_element->linkedElements().isEmpty())
|
||||
if(m_parent_element->linkType() == Element::Master)
|
||||
{
|
||||
|
||||
XRefProperties xrp = project->defaultXRefProperties(m_parent_element->kindInformations()["type"].toString());
|
||||
|
||||
if(xrp.snapTo() == XRefProperties::Label)
|
||||
|
||||
if(xrp.snapTo() == XRefProperties::Label &&
|
||||
(!m_parent_element->linkedElements().isEmpty()
|
||||
|| CrossRefItem::showAllConfiguredSlaves(m_parent_element, xrp)))
|
||||
{
|
||||
//At least one text owned by this group must be set with
|
||||
//textFrom -> element info and element info name -> label
|
||||
|
||||
@@ -101,6 +101,8 @@ class ElementTextItemGroup : public QObject, public QGraphicsItemGroup
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
|
||||
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;
|
||||
QVariant itemChange(GraphicsItemChange change,
|
||||
const QVariant &value) override;
|
||||
|
||||
private:
|
||||
void updateXref();
|
||||
@@ -120,6 +122,7 @@ class ElementTextItemGroup : public QObject, public QGraphicsItemGroup
|
||||
Element *m_parent_element = nullptr;
|
||||
QList<QMetaObject::Connection> m_update_slave_Xref_connection;
|
||||
QGraphicsTextItem *m_slave_Xref_item = nullptr;
|
||||
QMetaObject::Connection m_project_xref_connection;
|
||||
QMetaObject::Connection m_XrefChanged_timer,
|
||||
m_linked_changed_timer;
|
||||
};
|
||||
|
||||
@@ -63,6 +63,8 @@ IndependentTextItem::~IndependentTextItem()
|
||||
@param e L'element XML representant le champ de texte
|
||||
*/
|
||||
void IndependentTextItem::fromXml(const QDomElement &e) {
|
||||
const QUuid uuid(e.attribute(QStringLiteral("uuid")));
|
||||
if (!uuid.isNull() && uuid != m_uuid) setUuid(uuid);
|
||||
setPos(e.attribute("x").toDouble(), e.attribute("y").toDouble());
|
||||
setHtml(e.attribute("text"));
|
||||
setRotation(e.attribute("rotation").toDouble());
|
||||
@@ -81,6 +83,7 @@ void IndependentTextItem::fromXml(const QDomElement &e) {
|
||||
QDomElement IndependentTextItem::toXml(QDomDocument &document) const
|
||||
{
|
||||
QDomElement result = document.createElement("input");
|
||||
result.setAttribute("uuid", m_uuid.toString());
|
||||
result.setAttribute("x", QString("%1").arg(pos().x()));
|
||||
result.setAttribute("y", QString("%1").arg(pos().y()));
|
||||
result.setAttribute("text", toHtml());
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
#include "diagramtextitem.h"
|
||||
|
||||
#include <QUuid>
|
||||
|
||||
/**
|
||||
This class represents an independent text field on a particular diagram.
|
||||
It may be moved, edited, and rotated.
|
||||
@@ -29,6 +31,9 @@ class IndependentTextItem : public DiagramTextItem
|
||||
Q_OBJECT
|
||||
|
||||
// constructors, destructor
|
||||
signals:
|
||||
void uuidChanged();
|
||||
|
||||
public:
|
||||
IndependentTextItem();
|
||||
IndependentTextItem(const QString &);
|
||||
@@ -41,9 +46,14 @@ class IndependentTextItem : public DiagramTextItem
|
||||
|
||||
void fromXml(const QDomElement &) override;
|
||||
QDomElement toXml(QDomDocument &) const override;
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setUuid(const QUuid &uuid) {m_uuid = uuid; emit uuidChanged();}
|
||||
void newUuid() {setUuid(QUuid::createUuid());} //create new uuid for this item
|
||||
|
||||
protected:
|
||||
void focusOutEvent(QFocusEvent *event) override;
|
||||
|
||||
|
||||
private:
|
||||
QUuid m_uuid = QUuid::createUuid();
|
||||
};
|
||||
#endif
|
||||
|
||||
@@ -167,6 +167,14 @@ QVariant MasterElement::itemChange(QGraphicsItem::GraphicsItemChange change, con
|
||||
m_Xref_item = new CrossRefItem(this);
|
||||
m_Xref_item->updateLabel();
|
||||
}
|
||||
// Same idea for a master whose contact comb must show every
|
||||
// contact group it defines: the cross ref is expected even
|
||||
// before any slave is linked to it.
|
||||
else if (!m_Xref_item && mustShowXrefWithoutSlave())
|
||||
{
|
||||
m_Xref_item = new CrossRefItem(this);
|
||||
m_Xref_item->updateLabel();
|
||||
}
|
||||
}
|
||||
return Element::itemChange(change, value);
|
||||
}
|
||||
@@ -193,6 +201,26 @@ void MasterElement::xrefPropertiesChanged()
|
||||
aboutDeleteXref();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief MasterElement::mustShowXrefWithoutSlave
|
||||
@return true when the cross ref of this master has to be shown even
|
||||
though no slave is linked to it yet: the user asks the contact comb to
|
||||
display every contact group the master defines, the comb (contacts)
|
||||
display is the one in use, and the cross ref is owned by the element
|
||||
itself (snap to bottom).
|
||||
*/
|
||||
bool MasterElement::mustShowXrefWithoutSlave() const
|
||||
{
|
||||
if (!diagram() || !diagram()->project())
|
||||
return false;
|
||||
|
||||
const XRefProperties xrp = diagram()->project()->defaultXRefProperties(
|
||||
kindInformations()["type"].toString());
|
||||
|
||||
return xrp.snapTo() == XRefProperties::Bottom
|
||||
&& CrossRefItem::showAllConfiguredSlaves(this, xrp);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief MasterElement::aboutDeleteXref
|
||||
Check if Xref item must be displayed, if not, delete it.
|
||||
@@ -220,6 +248,11 @@ void MasterElement::aboutDeleteXref()
|
||||
return;
|
||||
}
|
||||
|
||||
// The contact comb shows the contact groups the master defines, linked
|
||||
// or not: keep the item even when it draws nothing so far.
|
||||
if (mustShowXrefWithoutSlave())
|
||||
return;
|
||||
|
||||
if (m_Xref_item->boundingRect().isNull())
|
||||
{
|
||||
delete m_Xref_item;
|
||||
|
||||
@@ -60,6 +60,7 @@ class MasterElement : public Element
|
||||
private:
|
||||
void xrefPropertiesChanged();
|
||||
void aboutDeleteXref ();
|
||||
bool mustShowXrefWithoutSlave() const;
|
||||
void connectSlavePositionUpdates(Element *slave);
|
||||
void disconnectSlavePositionUpdates(Element *slave);
|
||||
|
||||
|
||||
@@ -2385,7 +2385,7 @@ void QetShapeItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardMo
|
||||
: QetGraphicsHandlerUtility::rectForPosAtIndex(localRect(), localPos, index);
|
||||
|
||||
if (mods & Qt::ShiftModifier)
|
||||
newRect = lockAspectRatio(localRect(), newRect, index, mirrored);
|
||||
newRect = lockAspectRatio(QRectF(m_old_P1, m_old_P2).normalized(), newRect, index, mirrored);
|
||||
|
||||
setRect(newRect.normalized());
|
||||
}
|
||||
@@ -2891,6 +2891,11 @@ bool QetShapeItem::fromXml(const QDomElement &e)
|
||||
{
|
||||
if (e.tagName() != "shape") return (false);
|
||||
|
||||
//Absent in files written before shapes carried a uuid: keep the
|
||||
//one this item already has, Diagram::fromXml() settles it.
|
||||
const QUuid uuid(e.attribute(QStringLiteral("uuid")));
|
||||
if (!uuid.isNull() && uuid != m_uuid) setUuid(uuid);
|
||||
|
||||
// fromXml() is also used to *restore* an already-displayed item's
|
||||
// state (PromoteShapeCommand's undo/redo), not just to populate a
|
||||
// freshly constructed one -- without this, Qt has no way to know the
|
||||
@@ -3016,6 +3021,7 @@ bool QetShapeItem::fromXml(const QDomElement &e)
|
||||
QDomElement QetShapeItem::toXml(QDomDocument &document) const
|
||||
{
|
||||
QDomElement result = document.createElement("shape");
|
||||
result.setAttribute("uuid", m_uuid.toString());
|
||||
|
||||
//write some attribute
|
||||
QMetaEnum me = metaObject()->enumerator(metaObject()->indexOfEnumerator("ShapeType"));
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "shapetransform.h"
|
||||
|
||||
#include <QPen>
|
||||
#include <QUuid>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
@@ -73,7 +74,8 @@ class QetShapeItem : public QetGraphicsItem
|
||||
void YRadiusChanged();
|
||||
void transformChanged();
|
||||
void arcChanged();
|
||||
void geometryChanged(); // P1/P2, polygon points, or path nodes changed -- lets the properties panel stay in sync while a handle is dragged, not just when it's typed into
|
||||
void geometryChanged();
|
||||
void uuidChanged(); // P1/P2, polygon points, or path nodes changed -- lets the properties panel stay in sync while a handle is dragged, not just when it's typed into
|
||||
|
||||
public:
|
||||
enum ShapeType {Line =1,
|
||||
@@ -153,6 +155,9 @@ class QetShapeItem : public QetGraphicsItem
|
||||
|
||||
virtual bool fromXml (const QDomElement &);
|
||||
virtual QDomElement toXml (QDomDocument &document) const;
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setUuid(const QUuid &uuid) {m_uuid = uuid; emit uuidChanged();}
|
||||
void newUuid() {setUuid(QUuid::createUuid());} //create new uuid for this item
|
||||
virtual bool toDXF (const QString &filepath,const QPen &pen);
|
||||
|
||||
void editProperty() override;
|
||||
@@ -295,6 +300,7 @@ class QetShapeItem : public QetGraphicsItem
|
||||
|
||||
///ATTRIBUTES
|
||||
private:
|
||||
QUuid m_uuid = QUuid::createUuid();
|
||||
ShapeType m_shapeType;
|
||||
QPen m_pen;
|
||||
QBrush m_brush;
|
||||
|
||||
@@ -1395,11 +1395,12 @@ QStringList QetScriptApi::texts(int folioIndex) const
|
||||
{
|
||||
IndependentTextItem *t = list.at(i);
|
||||
const QPointF at = t->sceneBoundingRect().topLeft();
|
||||
out << QStringLiteral("%1: '%2' at (%3, %4)")
|
||||
out << QStringLiteral("%1: '%2' at (%3, %4) %5")
|
||||
.arg(i)
|
||||
.arg(t->toPlainText())
|
||||
.arg(at.x())
|
||||
.arg(at.y());
|
||||
.arg(at.y())
|
||||
.arg(t->uuid().toString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1518,10 +1519,11 @@ QStringList QetScriptApi::shapes(int folioIndex) const
|
||||
{
|
||||
QetShapeItem *shape = list.at(i);
|
||||
const QRectF r = shape->sceneBoundingRect();
|
||||
out << QStringLiteral("%1: %2 (%3, %4) to (%5, %6)")
|
||||
out << QStringLiteral("%1: %2 (%3, %4) to (%5, %6) %7")
|
||||
.arg(i)
|
||||
.arg(shape->name())
|
||||
.arg(r.left()).arg(r.top()).arg(r.right()).arg(r.bottom());
|
||||
.arg(r.left()).arg(r.top()).arg(r.right()).arg(r.bottom())
|
||||
.arg(shape->uuid().toString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -2836,13 +2838,52 @@ QStringList QetScriptApi::images(int folioIndex) const
|
||||
DiagramImageItem *item = list.at(i);
|
||||
const QPixmap px = item->pixmap();
|
||||
const QPointF at = item->sceneBoundingRect().topLeft();
|
||||
out << QStringLiteral("%1: %2x%3 px at (%4, %5) scale=%6 rotation=%7")
|
||||
out << QStringLiteral("%1: %2x%3 px at (%4, %5) scale=%6 rotation=%7 %8")
|
||||
.arg(i).arg(px.width()).arg(px.height()).arg(at.x()).arg(at.y())
|
||||
.arg(item->scaleFactorX()).arg(item->rotationAngle());
|
||||
.arg(item->scaleFactorX()).arg(item->rotationAngle())
|
||||
.arg(item->uuid().toString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
int indexOfUuid(const QList<T *> &sorted, const QString &uuid)
|
||||
{
|
||||
const QUuid wanted(uuid);
|
||||
if (wanted.isNull()) return -1;
|
||||
for (int i = 0 ; i < sorted.count() ; ++i) {
|
||||
if (sorted.at(i)->uuid() == wanted) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::textIndex
|
||||
The current index in texts() of the text carrying @p uuid, or -1.
|
||||
The uuid is what to hold across an edit; the index is what the other
|
||||
text calls take, and it shifts when a text is added or removed.
|
||||
*/
|
||||
int QetScriptApi::textIndex(int folioIndex, const QString &uuid) const
|
||||
{
|
||||
return indexOfUuid(sortedTexts(folioIndex), uuid);
|
||||
}
|
||||
|
||||
/// @brief QetScriptApi::shapeIndex as textIndex(), for shapes().
|
||||
int QetScriptApi::shapeIndex(int folioIndex, const QString &uuid) const
|
||||
{
|
||||
return indexOfUuid(sortedShapes(folioIndex), uuid);
|
||||
}
|
||||
|
||||
/// @brief QetScriptApi::imageIndex as textIndex(), for images().
|
||||
int QetScriptApi::imageIndex(int folioIndex, const QString &uuid) const
|
||||
{
|
||||
return indexOfUuid(sortedImages(folioIndex), uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::addImage
|
||||
Place a picture from a file, as the "add image" tool does after its file
|
||||
|
||||
@@ -139,14 +139,13 @@ class QetGraphicsTableItem;
|
||||
style editor changes.
|
||||
|
||||
These are addressed by @b index into a listing sorted by position
|
||||
(top to bottom, then left to right), because unlike an element they
|
||||
carry no uuid and unlike a conductor they have no terminal to be
|
||||
named by. Position is the only identity they have, and it persists,
|
||||
so the ordering is the same after a save and reload -- verified
|
||||
against exactly that. What it is @b not stable against is adding or
|
||||
deleting one: indexes after the affected position shift, the way a
|
||||
list's do. Call texts() or shapes() again rather than holding an
|
||||
index across an edit that adds or removes one.
|
||||
(top to bottom, then left to right), and the ordering is the same
|
||||
after a save and reload -- verified against exactly that. What it is
|
||||
@b not stable against is adding or deleting one: indexes after the
|
||||
affected position shift, the way a list's do. Each listing line ends
|
||||
with the item's uuid, which is persisted and does not shift: hold
|
||||
that across an edit, and turn it back into an index with textIndex(),
|
||||
shapeIndex() or imageIndex() when calling.
|
||||
- @b Querying the project database: run a read-only SELECT against the
|
||||
SQLite database QElectroTech builds from the project, and get rows
|
||||
back as objects. This is not a new door. QET already ships a
|
||||
@@ -432,6 +431,7 @@ class QetScriptApi : public QObject
|
||||
|
||||
// -- independent text and drawing shapes --
|
||||
Q_INVOKABLE QStringList texts(int folioIndex) const;
|
||||
Q_INVOKABLE int textIndex(int folioIndex, const QString &uuid) const;
|
||||
Q_INVOKABLE int addText(int folioIndex, const QString &text, double x, double y);
|
||||
Q_INVOKABLE QString textContent(int folioIndex, int textIndex) const;
|
||||
Q_INVOKABLE bool setTextContent(int folioIndex, int textIndex, const QString &text);
|
||||
@@ -440,6 +440,7 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE bool deleteText(int folioIndex, int textIndex);
|
||||
|
||||
Q_INVOKABLE QStringList shapes(int folioIndex) const;
|
||||
Q_INVOKABLE int shapeIndex(int folioIndex, const QString &uuid) const;
|
||||
Q_INVOKABLE int addShape(int folioIndex, const QString &type,
|
||||
double x1, double y1, double x2, double y2);
|
||||
Q_INVOKABLE bool deleteShape(int folioIndex, int shapeIndex);
|
||||
@@ -496,6 +497,7 @@ class QetScriptApi : public QObject
|
||||
|
||||
// -- images, embedded in the project --
|
||||
Q_INVOKABLE QStringList images(int folioIndex) const;
|
||||
Q_INVOKABLE int imageIndex(int folioIndex, const QString &uuid) const;
|
||||
Q_INVOKABLE int addImage(int folioIndex, const QString &filePath, double x, double y);
|
||||
Q_INVOKABLE bool setImageScale(int folioIndex, int imageIndex, double factor);
|
||||
Q_INVOKABLE bool setImageRotation(int folioIndex, int imageIndex, double angle);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "shortcutbarsettings.h"
|
||||
|
||||
#include "qetgraphicsitem/conductor.h"
|
||||
#include "shortcutmanager.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <algorithm>
|
||||
#include <QSettings>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
QString settingsKey(ShortcutBarSettings::Context context)
|
||||
{
|
||||
switch (context)
|
||||
{
|
||||
case ShortcutBarSettings::Canvas:
|
||||
return QStringLiteral("diagrameditor/shortcut_bar/canvas");
|
||||
case ShortcutBarSettings::Selection:
|
||||
return QStringLiteral("diagrameditor/shortcut_bar/selection");
|
||||
case ShortcutBarSettings::Conductor:
|
||||
return QStringLiteral("diagrameditor/shortcut_bar/conductor");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@return every context, in the order they are shown to the user
|
||||
*/
|
||||
QList<ShortcutBarSettings::Context> ShortcutBarSettings::contexts()
|
||||
{
|
||||
return {Canvas, Selection, Conductor};
|
||||
}
|
||||
|
||||
/**
|
||||
@return the context for @a selection: Canvas when empty, Conductor when
|
||||
it holds only conductors, Selection otherwise
|
||||
*/
|
||||
ShortcutBarSettings::Context ShortcutBarSettings::contextFor(
|
||||
const QList<QGraphicsItem *> &selection)
|
||||
{
|
||||
if (selection.isEmpty()) {
|
||||
return Canvas;
|
||||
}
|
||||
const bool only_conductors = std::all_of(
|
||||
selection.cbegin(), selection.cend(),
|
||||
[](QGraphicsItem *item) { return item->type() == Conductor::Type; });
|
||||
return only_conductors ? Conductor : Selection;
|
||||
}
|
||||
|
||||
/**
|
||||
@return the name of @a context, for the configuration page
|
||||
*/
|
||||
QString ShortcutBarSettings::title(Context context)
|
||||
{
|
||||
switch (context)
|
||||
{
|
||||
case Canvas:
|
||||
return QCoreApplication::translate("ShortcutBarSettings", "Folio, rien de sélectionné");
|
||||
case Selection:
|
||||
return QCoreApplication::translate("ShortcutBarSettings", "Éléments sélectionnés");
|
||||
case Conductor:
|
||||
return QCoreApplication::translate("ShortcutBarSettings", "Conducteurs sélectionnés");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
/**
|
||||
@return the ids to show for @a context: the user's list if they saved
|
||||
one, the defaults otherwise. A saved empty list stays empty.
|
||||
*/
|
||||
QStringList ShortcutBarSettings::ids(Context context)
|
||||
{
|
||||
QSettings settings;
|
||||
const QString key = settingsKey(context);
|
||||
if (!settings.contains(key)) {
|
||||
return defaultIds(context);
|
||||
}
|
||||
return settings.value(key).toStringList();
|
||||
}
|
||||
|
||||
/**
|
||||
@return the commands a new user sees for @a context
|
||||
*/
|
||||
QStringList ShortcutBarSettings::defaultIds(Context context)
|
||||
{
|
||||
switch (context)
|
||||
{
|
||||
case Canvas:
|
||||
return {QStringLiteral("diagrameditor.insert_last_element"),
|
||||
QStringLiteral("diagrameditor.show_element_picker"),
|
||||
QStringLiteral("diagrameditor.add_text"),
|
||||
QStringLiteral("diagrameditor.add_line"),
|
||||
QStringLiteral("diagrameditor.add_rectangle"),
|
||||
QStringLiteral("diagrameditor.add_terminal_strip"),
|
||||
QStringLiteral("diagrameditor.paste"),
|
||||
QStringLiteral("diagrameditor.edit_diagram_properties")};
|
||||
case Selection:
|
||||
return {QStringLiteral("diagrameditor.rotate_selection"),
|
||||
QStringLiteral("diagrameditor.rotate_texts"),
|
||||
QStringLiteral("diagrameditor.edit_selection"),
|
||||
QStringLiteral("diagrameditor.copy"),
|
||||
QStringLiteral("diagrameditor.cut"),
|
||||
QStringLiteral("diagrameditor.delete_selection")};
|
||||
case Conductor:
|
||||
return {QStringLiteral("diagrameditor.conductor_reset"),
|
||||
QStringLiteral("diagrameditor.edit_selection"),
|
||||
QStringLiteral("diagrameditor.delete_selection")};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ShortcutBarSettings::setIds
|
||||
Save @a ids for @a context. Saving the defaults removes the key, so a
|
||||
later change of defaults still reaches this user.
|
||||
*/
|
||||
void ShortcutBarSettings::setIds(Context context, const QStringList &ids)
|
||||
{
|
||||
QSettings settings;
|
||||
if (ids == defaultIds(context)) {
|
||||
settings.remove(settingsKey(context));
|
||||
} else {
|
||||
settings.setValue(settingsKey(context), ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@return every command that can go on the bar: those the diagram editor
|
||||
registered with ShortcutManager, except the one that opens the bar.
|
||||
*/
|
||||
QStringList ShortcutBarSettings::availableIds()
|
||||
{
|
||||
QStringList ids;
|
||||
for (const ShortcutManager::ShortcutInfo &info :
|
||||
ShortcutManager::instance().allShortcuts())
|
||||
{
|
||||
if (info.id.startsWith(QLatin1String("diagrameditor."))
|
||||
&& info.id != QLatin1String("diagrameditor.show_shortcut_bar")) {
|
||||
ids << info.id;
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
@return true if @a id is a pinned element's collection path rather than
|
||||
a command id. Command ids never contain "://".
|
||||
*/
|
||||
bool ShortcutBarSettings::isElement(const QString &id)
|
||||
{
|
||||
return id.contains(QLatin1String("://"));
|
||||
}
|
||||
|
||||
/**
|
||||
@return true if the row for @a context holds at least one element
|
||||
*/
|
||||
bool ShortcutBarSettings::hasElements(Context context)
|
||||
{
|
||||
const QStringList list = ids(context);
|
||||
return std::any_of(list.cbegin(), list.cend(), &ShortcutBarSettings::isElement);
|
||||
}
|
||||
|
||||
/**
|
||||
@return the width the user gave the bar with its size grip, or 0 when
|
||||
they never did: the tiles then stay on one row
|
||||
*/
|
||||
int ShortcutBarSettings::barWidth()
|
||||
{
|
||||
return QSettings().value(QStringLiteral("diagrameditor/shortcut_bar/width"), 0).toInt();
|
||||
}
|
||||
|
||||
void ShortcutBarSettings::setBarWidth(int width)
|
||||
{
|
||||
QSettings().setValue(QStringLiteral("diagrameditor/shortcut_bar/width"), width);
|
||||
}
|
||||
|
||||
/**
|
||||
@return the size the user left the customising window at, or an invalid
|
||||
size when it was never opened
|
||||
*/
|
||||
QSize ShortcutBarSettings::editorSize()
|
||||
{
|
||||
return QSettings().value(QStringLiteral("diagrameditor/shortcut_bar/editor_size")).toSize();
|
||||
}
|
||||
|
||||
void ShortcutBarSettings::setEditorSize(const QSize &size)
|
||||
{
|
||||
QSettings().setValue(QStringLiteral("diagrameditor/shortcut_bar/editor_size"), size);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef SHORTCUTBARSETTINGS_H
|
||||
#define SHORTCUTBARSETTINGS_H
|
||||
|
||||
#include <QList>
|
||||
|
||||
class QGraphicsItem;
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QSize>
|
||||
|
||||
/**
|
||||
@brief The commands shown on the diagram editor's shortcut bar.
|
||||
|
||||
The bar opens at the cursor and shows a different row of commands
|
||||
depending on what is selected. Each row is a list of ShortcutManager ids,
|
||||
so any registered command can go on it and the bar needs no command list
|
||||
of its own. Stored in QSettings, one key per context; a context the user
|
||||
never changed uses the defaults below.
|
||||
|
||||
A row can also hold elements the user pinned, by collection path
|
||||
("common://…", "custom://…", "company://…"), mixed in with the commands.
|
||||
Clicking one places that element.
|
||||
*/
|
||||
class ShortcutBarSettings
|
||||
{
|
||||
public:
|
||||
enum Context {
|
||||
Canvas, ///< nothing selected
|
||||
Selection, ///< elements, texts or shapes selected
|
||||
Conductor ///< only conductors selected
|
||||
};
|
||||
|
||||
static QList<Context> contexts();
|
||||
static Context contextFor(const QList<QGraphicsItem *> &selection);
|
||||
static QString title(Context context);
|
||||
static QStringList ids(Context context);
|
||||
static QStringList defaultIds(Context context);
|
||||
static void setIds(Context context, const QStringList &ids);
|
||||
static QStringList availableIds();
|
||||
static bool isElement(const QString &id);
|
||||
static bool hasElements(Context context);
|
||||
static int barWidth();
|
||||
static void setBarWidth(int width);
|
||||
static QSize editorSize();
|
||||
static void setEditorSize(const QSize &size);
|
||||
};
|
||||
|
||||
#endif // SHORTCUTBARSETTINGS_H
|
||||
@@ -197,3 +197,36 @@ bool ShortcutManager::trigger(const QString &id) const
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@return the QAction registered under @a id that belongs to @a owner --
|
||||
that is, has @a owner among its ancestors -- or nullptr. Several windows
|
||||
of the same kind each register their own action under one id, so a
|
||||
window asking for "its" action has to say which window it is.
|
||||
@param id
|
||||
@param owner : the window, or nullptr for the first live action
|
||||
*/
|
||||
QAction *ShortcutManager::action(const QString &id, const QObject *owner) const
|
||||
{
|
||||
auto it = m_entries.find(id);
|
||||
if (it == m_entries.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
for (const QPointer<QObject> &target : qAsConst(it->targets))
|
||||
{
|
||||
auto *action = qobject_cast<QAction *>(target.data());
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
if (!owner) {
|
||||
return action;
|
||||
}
|
||||
for (const QObject *o = action->parent(); o; o = o->parent()) {
|
||||
if (o == owner) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <QStringList>
|
||||
|
||||
class QObject;
|
||||
class QAction;
|
||||
|
||||
/**
|
||||
@brief The ShortcutManager class
|
||||
@@ -84,6 +85,7 @@ class ShortcutManager
|
||||
/// multi-window case, not a guaranteed-correct dispatch.
|
||||
/// @return whether a live target was found and triggered.
|
||||
bool trigger(const QString &id) const;
|
||||
QAction *action(const QString &id, const QObject *owner) const;
|
||||
|
||||
private:
|
||||
ShortcutManager() = default;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "connexionbackend.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFileInfo>
|
||||
#include <QMutex>
|
||||
#include <QMutexLocker>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
//The part of 3DconnexionClient's interface QET uses, with the
|
||||
//values Blender uses (intern/ghost/intern/GHOST_NDOFManagerCocoa.mm).
|
||||
constexpr quint32 MSG_DEVICE_STATE = 0x33645352; // '3dSR'
|
||||
constexpr quint16 CMD_HANDLE_BUTTONS = 2;
|
||||
constexpr quint16 CMD_HANDLE_AXIS = 3;
|
||||
constexpr quint16 MODE_TAKE_OVER = 1;
|
||||
constexpr quint32 MASK_ALL = 0x3fff;
|
||||
constexpr quint32 MASK_ALL_BUTTONS = 0xffffffff;
|
||||
constexpr quint32 SIGNATURE = 0x51456c54; // 'QElT'
|
||||
|
||||
//ConnexionDeviceState is packed to 2 bytes. Offsets of the fields read.
|
||||
constexpr int STATE_CLIENT = 2;
|
||||
constexpr int STATE_COMMAND = 4;
|
||||
constexpr int STATE_AXIS = 30;
|
||||
constexpr int STATE_BUTTONS = 44;
|
||||
|
||||
using MessageHandler = void (*)(quint32, quint32, void *);
|
||||
using DeviceHandler = void (*)(quint32);
|
||||
using SetConnexionHandlers = qint16 (*)(MessageHandler, DeviceHandler, DeviceHandler, bool);
|
||||
using RegisterConnexionClient = quint16 (*)(quint32, const quint8 *, quint16, quint32);
|
||||
using SetConnexionClientButtonMask = void (*)(quint16, quint32);
|
||||
using CleanupConnexionHandlers = void (*)();
|
||||
using UnregisterConnexionClient = void (*)(quint16);
|
||||
|
||||
//3DxWare's callbacks carry no context, so the one client lives here.
|
||||
//The mutex is shared with 3DxWare's thread.
|
||||
QMutex instance_mutex;
|
||||
ConnexionBackend *instance = nullptr;
|
||||
|
||||
void deviceChanged(quint32) {}
|
||||
}
|
||||
|
||||
const char ConnexionBackend::DEFAULT_LIBRARY[] =
|
||||
"/Library/Frameworks/3DconnexionClient.framework/3DconnexionClient";
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::ConnexionBackend
|
||||
Load 3DxWare's client library and register with it. Not installed, or
|
||||
installed but not running, leaves isAvailable() false: the ordinary
|
||||
case, never reported as an error.
|
||||
@param parent
|
||||
@param library : the client library; only tests pass another
|
||||
*/
|
||||
ConnexionBackend::ConnexionBackend(QObject *parent, const QString &library) :
|
||||
SpaceMouseBackend(parent),
|
||||
m_library(library)
|
||||
{
|
||||
{
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
if (instance) {
|
||||
return; //3DxWare takes one set of handlers per process
|
||||
}
|
||||
}
|
||||
if (!m_library.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto set_handlers = reinterpret_cast<SetConnexionHandlers>(
|
||||
m_library.resolve("SetConnexionHandlers"));
|
||||
const auto register_client = reinterpret_cast<RegisterConnexionClient>(
|
||||
m_library.resolve("RegisterConnexionClient"));
|
||||
const auto button_mask = reinterpret_cast<SetConnexionClientButtonMask>(
|
||||
m_library.resolve("SetConnexionClientButtonMask"));
|
||||
m_cleanup = reinterpret_cast<CleanupConnexionHandlers>(
|
||||
m_library.resolve("CleanupConnexionHandlers"));
|
||||
m_unregister = reinterpret_cast<UnregisterConnexionClient>(
|
||||
m_library.resolve("UnregisterConnexionClient"));
|
||||
if (!set_handlers || !register_client || !m_cleanup || !m_unregister) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
instance = this;
|
||||
}
|
||||
//Fails while 3DxWare is installed but its driver is not running.
|
||||
if (set_handlers(&ConnexionBackend::onMessage, deviceChanged, deviceChanged, true) != 0) {
|
||||
shutdown();
|
||||
return;
|
||||
}
|
||||
m_handlers_installed = true;
|
||||
|
||||
//3DxWare only sends a client its messages while that application
|
||||
//is in front, and recognises it by its executable's name (a Pascal
|
||||
//string). Take-over mode stops 3DxWare's own actions in QET, so the
|
||||
//view never moves twice.
|
||||
QByteArray name = QFileInfo(QCoreApplication::applicationFilePath())
|
||||
.fileName().toUtf8().left(255);
|
||||
name.prepend(char(name.size()));
|
||||
m_client = register_client(SIGNATURE,
|
||||
reinterpret_cast<const quint8 *>(name.constData()),
|
||||
MODE_TAKE_OVER, MASK_ALL);
|
||||
if (!m_client) {
|
||||
shutdown();
|
||||
return;
|
||||
}
|
||||
if (button_mask) {
|
||||
button_mask(m_client, MASK_ALL_BUTTONS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::~ConnexionBackend
|
||||
*/
|
||||
ConnexionBackend::~ConnexionBackend()
|
||||
{
|
||||
shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::sampleFromAxes
|
||||
3DxWare reports y up and z away from the user; the raw USB reports, and
|
||||
so every other backend, have y towards the user and z down. Derived from
|
||||
Blender, whose 3DxWare and spacenavd code paths must agree.
|
||||
@param axis : TX, TY, TZ, RX, RY, RZ as 3DxWare sends them
|
||||
@return the same movement in QET's convention
|
||||
*/
|
||||
SpaceMouseSample ConnexionBackend::sampleFromAxes(const qint16 axis[6])
|
||||
{
|
||||
SpaceMouseSample sample;
|
||||
sample.x = axis[0];
|
||||
sample.y = -axis[2];
|
||||
sample.z = -axis[1];
|
||||
sample.rx = axis[3];
|
||||
sample.ry = -axis[5];
|
||||
sample.rz = -axis[4];
|
||||
return sample;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::newlyPressed
|
||||
@param before : the button bitmask of the previous message
|
||||
@param now : the button bitmask of this one
|
||||
@return the 0-based buttons pressed since \a before, as HidBackend numbers them
|
||||
*/
|
||||
QList<int> ConnexionBackend::newlyPressed(quint32 before, quint32 now)
|
||||
{
|
||||
QList<int> pressed;
|
||||
const quint32 down = now & ~before;
|
||||
for (int bit = 0; bit < 32; ++bit) {
|
||||
if (down & (quint32(1) << bit)) {
|
||||
pressed.append(bit);
|
||||
}
|
||||
}
|
||||
return pressed;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::onMessage
|
||||
Runs on 3DxWare's thread. Copies what matters out of the message and
|
||||
hands it to the main thread.
|
||||
@param type : the message type
|
||||
@param argument : a ConnexionDeviceState for MSG_DEVICE_STATE
|
||||
*/
|
||||
void ConnexionBackend::onMessage(quint32, quint32 type, void *argument)
|
||||
{
|
||||
if (type != MSG_DEVICE_STATE || !argument) {
|
||||
return;
|
||||
}
|
||||
const char *state = static_cast<const char *>(argument);
|
||||
quint16 client;
|
||||
quint16 command;
|
||||
qint16 axis[6];
|
||||
quint32 buttons;
|
||||
std::memcpy(&client, state + STATE_CLIENT, sizeof client);
|
||||
std::memcpy(&command, state + STATE_COMMAND, sizeof command);
|
||||
std::memcpy(axis, state + STATE_AXIS, sizeof axis);
|
||||
std::memcpy(&buttons, state + STATE_BUTTONS, sizeof buttons);
|
||||
|
||||
//Queued with the backend as context: if it is deleted before the
|
||||
//main thread gets to it, Qt drops the call.
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
ConnexionBackend *backend = instance;
|
||||
if (!backend) {
|
||||
return;
|
||||
}
|
||||
QMetaObject::invokeMethod(backend, [=]() {
|
||||
backend->handleState(client, command, axis, buttons);
|
||||
}, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::handleState
|
||||
On the main thread: turn one device state into signals.
|
||||
*/
|
||||
void ConnexionBackend::handleState(quint16 client, quint16 command,
|
||||
const qint16 axis[6], quint32 buttons)
|
||||
{
|
||||
if (client != m_client) {
|
||||
return; //3DxWare sends every state to every client
|
||||
}
|
||||
if (command == CMD_HANDLE_AXIS) {
|
||||
emit motion(sampleFromAxes(axis));
|
||||
} else if (command == CMD_HANDLE_BUTTONS) {
|
||||
//State first: a button can open a dialog whose event loop
|
||||
//delivers the next message before emit returns.
|
||||
const QList<int> pressed = newlyPressed(m_buttons, buttons);
|
||||
m_buttons = buttons;
|
||||
for (int button : pressed) {
|
||||
emit buttonPressed(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::shutdown
|
||||
Unregister and stop 3DxWare's callbacks. The library stays loaded, since
|
||||
3DxWare's thread may still be returning from one.
|
||||
*/
|
||||
void ConnexionBackend::shutdown()
|
||||
{
|
||||
if (m_client && m_unregister) {
|
||||
m_unregister(m_client);
|
||||
}
|
||||
m_client = 0;
|
||||
if (m_handlers_installed && m_cleanup) {
|
||||
m_cleanup();
|
||||
}
|
||||
m_handlers_installed = false;
|
||||
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
if (instance == this) {
|
||||
instance = nullptr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef CONNEXIONBACKEND_H
|
||||
#define CONNEXIONBACKEND_H
|
||||
|
||||
#include "spacemousebackend.h"
|
||||
|
||||
#include <QLibrary>
|
||||
|
||||
/**
|
||||
@brief The ConnexionBackend class
|
||||
SpaceMouseBackend that reads the device through 3Dconnexion's own macOS
|
||||
driver, 3DxWare, the way Blender does. Only compiled in on macOS
|
||||
(cmake/find_spacemouse.cmake).
|
||||
|
||||
3DxWare installs a driver extension that takes the device over, so
|
||||
with 3DxWare installed HidBackend can open the device but receives
|
||||
nothing (discussion #599). This backend asks 3DxWare for the motion
|
||||
instead. SpaceMouseListener tries it first and falls back to HidBackend
|
||||
when 3DxWare is not installed or not running, so the device works
|
||||
either way.
|
||||
|
||||
The client library, 3DconnexionClient.framework, is loaded at run time
|
||||
from where 3DxWare installs it: nothing is linked, bundled or needed to
|
||||
build. The few declarations below are written here, as Blender does,
|
||||
rather than taken from 3Dconnexion's SDK, whose headers may not be
|
||||
redistributed.
|
||||
|
||||
3DxWare delivers its messages on a thread of its own; they are passed
|
||||
to the main thread before any signal is emitted.
|
||||
*/
|
||||
class ConnexionBackend : public SpaceMouseBackend
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static const char DEFAULT_LIBRARY[];
|
||||
|
||||
explicit ConnexionBackend(QObject *parent = nullptr,
|
||||
const QString &library = QString::fromLatin1(DEFAULT_LIBRARY));
|
||||
~ConnexionBackend() override;
|
||||
|
||||
bool isAvailable() const override { return m_client != 0; }
|
||||
|
||||
/// 3DxWare's six axes, in its own order and signs, as QET's
|
||||
/// raw USB convention (+x right, +y towards the user, +z down).
|
||||
static SpaceMouseSample sampleFromAxes(const qint16 axis[6]);
|
||||
/// The 0-based buttons set in \a now but not in \a before.
|
||||
static QList<int> newlyPressed(quint32 before, quint32 now);
|
||||
|
||||
private:
|
||||
static void onMessage(quint32 connection, quint32 type, void *argument);
|
||||
void handleState(quint16 client, quint16 command,
|
||||
const qint16 axis[6], quint32 buttons);
|
||||
void shutdown();
|
||||
|
||||
QLibrary m_library;
|
||||
quint16 m_client = 0;
|
||||
bool m_handlers_installed = false;
|
||||
quint32 m_buttons = 0;
|
||||
|
||||
void (*m_cleanup)() = nullptr;
|
||||
void (*m_unregister)(quint16) = nullptr;
|
||||
};
|
||||
|
||||
#endif // CONNEXIONBACKEND_H
|
||||
@@ -36,7 +36,9 @@
|
||||
Two implementations, chosen at build time (cmake/find_spacemouse.cmake):
|
||||
SpnavBackend (Linux, through spacenavd/libspnav) and HidBackend (any
|
||||
platform, directly over USB through hidapi, with no 3Dconnexion driver
|
||||
or SDK). Both report the same values for the same movement.
|
||||
or SDK). On macOS a third, ConnexionBackend, reads through 3DxWare and
|
||||
is tried first at run time. All report the same values for the same
|
||||
movement.
|
||||
*/
|
||||
class SpaceMouseBackend : public QObject
|
||||
{
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
#ifdef QET_SPACEMOUSE_BACKEND_HID
|
||||
# include "hidbackend.h"
|
||||
#endif
|
||||
#ifdef QET_SPACEMOUSE_BACKEND_CONNEXION
|
||||
# include "connexionbackend.h"
|
||||
#endif
|
||||
|
||||
#include "../diagramview.h"
|
||||
#include "../editor/elementview.h"
|
||||
@@ -48,10 +51,24 @@ SpaceMouseListener::SpaceMouseListener(QObject *parent) :
|
||||
QObject(parent),
|
||||
m_settings(SpaceMouseSettings::load())
|
||||
{
|
||||
#if defined(QET_SPACEMOUSE_BACKEND_CONNEXION)
|
||||
//When 3DxWare is installed and running it has the device to
|
||||
//itself, so ask it first; otherwise read the device directly.
|
||||
auto *connexion = new ConnexionBackend(this);
|
||||
if (connexion->isAvailable()) {
|
||||
m_backend = connexion;
|
||||
} else {
|
||||
delete connexion;
|
||||
}
|
||||
#endif
|
||||
#if defined(QET_SPACEMOUSE_BACKEND_SPNAV)
|
||||
m_backend = new SpnavBackend(this);
|
||||
if (!m_backend) {
|
||||
m_backend = new SpnavBackend(this);
|
||||
}
|
||||
#elif defined(QET_SPACEMOUSE_BACKEND_HID)
|
||||
m_backend = new HidBackend(this);
|
||||
if (!m_backend) {
|
||||
m_backend = new HidBackend(this);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (m_backend) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef TEXTGRID_H
|
||||
#define TEXTGRID_H
|
||||
|
||||
#include <QList>
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
#include <QtMath>
|
||||
|
||||
/**
|
||||
The text grid: the step texts snap to when dragged with the mouse,
|
||||
a fraction of the folio grid. A divisor of 1 is the folio grid itself,
|
||||
0 means no grid. Because every step divides the folio grid, a text
|
||||
snapped to it still lines up with every element and with the texts
|
||||
of other elements.
|
||||
*/
|
||||
namespace TextGrid
|
||||
{
|
||||
/// The choices offered in the menu and the preferences, 0 first.
|
||||
inline const QList<qreal> divisors{0, 1, 2, 5, 10};
|
||||
|
||||
/// QSettings key holding the divisor.
|
||||
inline const QString settings_key{QStringLiteral("diagrameditor/text_grid_divisor")};
|
||||
|
||||
/// "1:5" for 5. Not meaningful for 0.
|
||||
inline QString ratioLabel(qreal divisor) {
|
||||
return QStringLiteral("1:") + QString::number(divisor);
|
||||
}
|
||||
|
||||
/**
|
||||
@return p snapped to a grid of x_grid / divisor by y_grid / divisor,
|
||||
or rounded to the nearest pixel when divisor is 0 or less.
|
||||
*/
|
||||
inline QPointF snap(const QPointF &p, int x_grid, int y_grid, qreal divisor)
|
||||
{
|
||||
if (divisor <= 0 || x_grid <= 0 || y_grid <= 0)
|
||||
return QPointF(qRound(p.x()), qRound(p.y()));
|
||||
|
||||
const qreal x_step = x_grid / divisor;
|
||||
const qreal y_step = y_grid / divisor;
|
||||
return QPointF(qRound(p.x() / x_step) * x_step,
|
||||
qRound(p.y() / y_step) * y_step);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // TEXTGRID_H
|
||||
@@ -1993,32 +1993,58 @@ void TitleBlockTemplate::renderTextCellDxf(
|
||||
}
|
||||
|
||||
//painter.setFont(text_font);
|
||||
qreal ratio = 1.0;
|
||||
|
||||
if (cell.hadjust)
|
||||
//A cell can hold several lines, which the screen shows one under the
|
||||
//other (QPainter::drawText()). A DXF TEXT is a single line, and a line
|
||||
//break inside one breaks the file (FINDINGS F052), so each line gets
|
||||
//its own, stacked as the cell's vertical alignment says: the first at
|
||||
//y1 when aligned to the top, the last at y1 when aligned to the
|
||||
//bottom, the block centred on y1 otherwise. Spacing as the diagram
|
||||
//texts' DXF export: 1.6 times the text height. A single line is
|
||||
//written exactly as before.
|
||||
const QStringList lines = text.split(QRegularExpression(QStringLiteral("\r\n|\r|\n")));
|
||||
const qreal line_spacing = textHeight * Createdxf::yScale * 1.6;
|
||||
const int last = lines.size() - 1;
|
||||
for (int i = 0 ; i < lines.size() ; ++i)
|
||||
{
|
||||
// Scale font width to fit string in cell width w
|
||||
// As DXF font aspect ratio is implementation dependent we add a fudge-factor based on tests with AutoCAD
|
||||
int len = text.length() * textHeight * Createdxf::xScale * 1.2;
|
||||
const QString &line = lines.at(i);
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
if(len > w)
|
||||
ratio = (w/len);
|
||||
qreal offset; // upward, from y1
|
||||
if (vAlign == 3)
|
||||
offset = -i * line_spacing;
|
||||
else if (vAlign == 2)
|
||||
offset = (last / 2.0 - i) * line_spacing;
|
||||
else
|
||||
offset = (last - i) * line_spacing;
|
||||
|
||||
qreal ratio = 1.0;
|
||||
|
||||
if (cell.hadjust)
|
||||
{
|
||||
// Scale font width to fit string in cell width w
|
||||
// As DXF font aspect ratio is implementation dependent we add a fudge-factor based on tests with AutoCAD
|
||||
int len = line.length() * textHeight * Createdxf::xScale * 1.2;
|
||||
|
||||
if(len > w)
|
||||
ratio = (w/len);
|
||||
}
|
||||
|
||||
// x offset value below currently set heuristically based on appearance...
|
||||
Createdxf::drawTextAligned(
|
||||
file_path,
|
||||
line,
|
||||
x - 2*Createdxf::xScale,
|
||||
y1 + offset,
|
||||
textHeight*Createdxf::yScale,
|
||||
0,
|
||||
0,
|
||||
hAlign,
|
||||
vAlign,
|
||||
x2,
|
||||
ratio,
|
||||
color);
|
||||
}
|
||||
|
||||
// x offset value below currently set heuristically based on appearance...
|
||||
Createdxf::drawTextAligned(
|
||||
file_path,
|
||||
text,
|
||||
x - 2*Createdxf::xScale,
|
||||
y1,
|
||||
textHeight*Createdxf::yScale,
|
||||
0,
|
||||
0,
|
||||
hAlign,
|
||||
vAlign,
|
||||
x2,
|
||||
ratio,
|
||||
color);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "conductorpropertieseditorwidget.h"
|
||||
|
||||
#include "../QPropertyUndoCommand/qpropertyundocommand.h"
|
||||
#include "../diagram.h"
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
#include "conductorpropertieswidget.h"
|
||||
#include "../qtextorientationspinboxwidget.h"
|
||||
|
||||
#include <KColorButton>
|
||||
|
||||
#include <QAbstractButton>
|
||||
#include <QAbstractSpinBox>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QGroupBox>
|
||||
#include <QLineEdit>
|
||||
#include <QScrollArea>
|
||||
#include <QSettings>
|
||||
#include <QSizePolicy>
|
||||
#include <QSlider>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget
|
||||
@param conductor : conductor to edit
|
||||
@param parent : parent widget
|
||||
*/
|
||||
ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget(
|
||||
Conductor *conductor, QWidget *parent) :
|
||||
PropertiesEditorWidget(parent),
|
||||
m_cpw(new ConductorPropertiesWidget(this))
|
||||
{
|
||||
// The conductor widget is dialog-sized (min width ~600px), far too wide for
|
||||
// a dock. Host it in a scroll area with a small minimum width so the dock
|
||||
// can be dragged to any width (a scrollbar appears when narrower than the
|
||||
// content). QET already persists dock geometry across restarts via
|
||||
// QETDiagramEditor save/restoreState, so the chosen width is remembered.
|
||||
// "Apply to all conductors of the potential": same semantics as the modal
|
||||
// dialog's checkbox (ConductorPropertiesDialog::applyAll), pinned at the top
|
||||
// of the panel so it stays visible above the scrolling tabs (#500). Unlike
|
||||
// the dialog the choice is persisted, so a user who always wants it on (or
|
||||
// off) sets it once. It is a child of this editor, not of m_cpw, so it is
|
||||
// deliberately outside the live-edit signal wiring in connectChangeSignals()
|
||||
// (toggling it must not push an edit, only change how the next edit applies).
|
||||
// Reuse the modal dialog's exact wording for consistency (and so the
|
||||
// existing translation applies).
|
||||
m_apply_all_cb = new QCheckBox(
|
||||
tr("Appliquer les propriétés à l'ensemble des conducteurs de ce potentiel"),
|
||||
this);
|
||||
m_apply_all_cb->setChecked(QSettings().value(
|
||||
QStringLiteral("diagrameditor/conductor_apply_all"), true).toBool());
|
||||
connect(m_apply_all_cb, &QCheckBox::toggled, this, [](bool on) {
|
||||
QSettings().setValue(
|
||||
QStringLiteral("diagrameditor/conductor_apply_all"), on);
|
||||
});
|
||||
|
||||
auto *scroll = new QScrollArea(this);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
scroll->setWidget(m_cpw);
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->addWidget(m_apply_all_cb);
|
||||
layout->addWidget(scroll);
|
||||
setMinimumWidth(120);
|
||||
// Expand vertically to fill the dock like the other editors do (otherwise
|
||||
// the panel sits at its small size hint with empty space below it, #500),
|
||||
// while keeping a minimum height so it stays usable when the dock is short.
|
||||
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
|
||||
setMinimumHeight(200);
|
||||
setDisabled(true);
|
||||
setConductor(conductor);
|
||||
}
|
||||
|
||||
ConductorPropertiesEditorWidget::~ConductorPropertiesEditorWidget()
|
||||
{}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::setConductor
|
||||
Set (or change) the conductor whose properties are edited.
|
||||
@param conductor
|
||||
*/
|
||||
void ConductorPropertiesEditorWidget::setConductor(Conductor *conductor)
|
||||
{
|
||||
if (!conductor) return;
|
||||
if (m_conductor && m_conductor != conductor)
|
||||
disconnect(m_conductor, &Conductor::propertiesChange,
|
||||
this, &ConductorPropertiesEditorWidget::updateUi);
|
||||
m_conductor = conductor;
|
||||
//Keep the dock in sync when the conductor is edited elsewhere (e.g. the
|
||||
//modal "Edit conductor" dialog); otherwise a stale snapshot would be
|
||||
//written back on the next apply() and overwrite that change (issue #500).
|
||||
connect(m_conductor, &Conductor::propertiesChange,
|
||||
this, &ConductorPropertiesEditorWidget::updateUi, Qt::UniqueConnection);
|
||||
setEnabled(true);
|
||||
updateUi();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::apply
|
||||
Push the edit onto the diagram's undo stack.
|
||||
*/
|
||||
void ConductorPropertiesEditorWidget::apply()
|
||||
{
|
||||
// Ignore the field-change signals emitted while the widget is being loaded
|
||||
// programmatically (updateUi/reset): mid-load the widget holds a partial
|
||||
// state that must not be committed onto the conductor.
|
||||
if (m_updating) return;
|
||||
if (!m_conductor || !m_conductor->diagram()) return;
|
||||
if (QUndoCommand *undo = associatedUndo())
|
||||
m_conductor->diagram()->undoStack().push(undo);
|
||||
m_initial = m_conductor->properties();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::setLiveEdit
|
||||
In live-edit mode (how the dock uses every editor), each field change is
|
||||
applied immediately instead of via an explicit apply() call. Without this
|
||||
override the base class is a no-op and edits in the dock were never applied
|
||||
(issue #500).
|
||||
@param live_edit true to enable live edit
|
||||
@return always true
|
||||
*/
|
||||
bool ConductorPropertiesEditorWidget::setLiveEdit(bool live_edit)
|
||||
{
|
||||
if (m_live_edit == live_edit) return true;
|
||||
m_live_edit = live_edit;
|
||||
|
||||
if (m_live_edit) connectChangeSignals();
|
||||
else disconnectChangeSignals();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::connectChangeSignals
|
||||
Wire every editable control of the hosted ConductorPropertiesWidget to
|
||||
apply(). Commit-style signals (editingFinished / activated / toggled /
|
||||
sliderReleased) are used rather than per-keystroke ones so each edit yields
|
||||
a single, clean undo step. Loading the widget programmatically (updateUi)
|
||||
also fires some of these, but apply() is a no-op then because
|
||||
associatedUndo() returns nullptr when the properties are unchanged.
|
||||
*/
|
||||
void ConductorPropertiesEditorWidget::connectChangeSignals()
|
||||
{
|
||||
if (!m_cpw) return;
|
||||
|
||||
const auto add = [this](QMetaObject::Connection c) {
|
||||
m_live_connections << c;
|
||||
};
|
||||
|
||||
for (auto *w : m_cpw->findChildren<QLineEdit *>())
|
||||
add(connect(w, &QLineEdit::editingFinished,
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
for (auto *w : m_cpw->findChildren<QAbstractSpinBox *>())
|
||||
add(connect(w, &QAbstractSpinBox::editingFinished,
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
for (auto *w : m_cpw->findChildren<QComboBox *>())
|
||||
add(connect(w, QOverload<int>::of(&QComboBox::activated),
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
for (auto *w : m_cpw->findChildren<QSlider *>())
|
||||
add(connect(w, &QSlider::sliderReleased,
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
for (auto *w : m_cpw->findChildren<KColorButton *>())
|
||||
add(connect(w, &KColorButton::changed,
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
for (auto *w : m_cpw->findChildren<QTextOrientationSpinBoxWidget *>())
|
||||
add(connect(w, QOverload<>::of(&QTextOrientationSpinBoxWidget::editingFinished),
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
// Checkboxes and the checkable group boxes (single/multi wire, bicolor…).
|
||||
for (auto *w : m_cpw->findChildren<QAbstractButton *>())
|
||||
if (w->isCheckable())
|
||||
add(connect(w, &QAbstractButton::toggled,
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
for (auto *w : m_cpw->findChildren<QGroupBox *>())
|
||||
if (w->isCheckable())
|
||||
add(connect(w, &QGroupBox::toggled,
|
||||
this, &ConductorPropertiesEditorWidget::apply));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::disconnectChangeSignals
|
||||
Tear down the live-edit connections made by connectChangeSignals().
|
||||
*/
|
||||
void ConductorPropertiesEditorWidget::disconnectChangeSignals()
|
||||
{
|
||||
for (const QMetaObject::Connection &c : m_live_connections)
|
||||
disconnect(c);
|
||||
m_live_connections.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::reset
|
||||
Discard the in-progress edit, restoring the conductor's current properties.
|
||||
*/
|
||||
void ConductorPropertiesEditorWidget::reset()
|
||||
{
|
||||
if (!m_conductor) return;
|
||||
m_updating = true;
|
||||
m_cpw->setProperties(m_initial);
|
||||
m_updating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::updateUi
|
||||
Reload the widget from the conductor (e.g. when the selection changes).
|
||||
*/
|
||||
void ConductorPropertiesEditorWidget::updateUi()
|
||||
{
|
||||
if (!m_conductor) return;
|
||||
m_updating = true;
|
||||
m_initial = m_conductor->properties();
|
||||
m_cpw->setProperties(m_initial);
|
||||
m_updating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::associatedUndo
|
||||
@return the edit as a QPropertyUndoCommand, or nullptr if unchanged.
|
||||
|
||||
When "apply to all" is ticked, every conductor on the same potential is
|
||||
updated in the same undo step (one undo reverts them all), exactly as the
|
||||
modal dialog does (ConductorPropertiesDialog::PropertiesDialog). Otherwise
|
||||
only the selected conductor is changed.
|
||||
*/
|
||||
QUndoCommand *ConductorPropertiesEditorWidget::associatedUndo() const
|
||||
{
|
||||
if (!m_conductor) return nullptr;
|
||||
|
||||
const ConductorProperties new_properties = m_cpw->properties();
|
||||
if (new_properties == m_conductor->properties()) return nullptr;
|
||||
|
||||
QVariant old_value, new_value;
|
||||
old_value.setValue(m_conductor->properties());
|
||||
new_value.setValue(new_properties);
|
||||
|
||||
auto *undo = new QPropertyUndoCommand(
|
||||
m_conductor, "properties", old_value, new_value);
|
||||
undo->setText(tr("Modifier les propriétés d'un conducteur", "undo caption"));
|
||||
|
||||
// Propagate to every conductor on the same potential, as the modal dialog
|
||||
// does: each related conductor becomes a child command of the same undo
|
||||
// step, set to the same target properties.
|
||||
if (m_apply_all_cb && m_apply_all_cb->isChecked())
|
||||
{
|
||||
const auto potential = m_conductor->relatedPotentialConductors();
|
||||
if (!potential.isEmpty())
|
||||
{
|
||||
undo->setText(tr("Modifier les propriétés de plusieurs conducteurs",
|
||||
"undo caption"));
|
||||
for (Conductor *potential_conductor : potential)
|
||||
{
|
||||
QVariant old_v;
|
||||
old_v.setValue(potential_conductor->properties());
|
||||
new QPropertyUndoCommand(
|
||||
potential_conductor, "properties", old_v, new_value, undo);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undo;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorPropertiesEditorWidget::title
|
||||
@return the panel title.
|
||||
*/
|
||||
QString ConductorPropertiesEditorWidget::title() const
|
||||
{
|
||||
return tr("Conducteur");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef CONDUCTORPROPERTIESEDITORWIDGET_H
|
||||
#define CONDUCTORPROPERTIESEDITORWIDGET_H
|
||||
|
||||
#include "../PropertiesEditor/propertieseditorwidget.h"
|
||||
#include "../conductorproperties.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QMetaObject>
|
||||
|
||||
class Conductor;
|
||||
class ConductorPropertiesWidget;
|
||||
class QCheckBox;
|
||||
|
||||
/**
|
||||
@brief The ConductorPropertiesEditorWidget class
|
||||
Hosts the existing ConductorPropertiesWidget in the dockable selection-
|
||||
properties panel, so a selected conductor can be edited in place like the
|
||||
other item types, instead of only through the modal dialog (issue #500).
|
||||
A pinned "apply to all conductors of the potential" checkbox (persisted)
|
||||
mirrors the modal dialog's option to propagate edits to the whole potential.
|
||||
*/
|
||||
class ConductorPropertiesEditorWidget : public PropertiesEditorWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ConductorPropertiesEditorWidget(
|
||||
Conductor *conductor = nullptr, QWidget *parent = nullptr);
|
||||
~ConductorPropertiesEditorWidget() override;
|
||||
|
||||
void setConductor(Conductor *conductor);
|
||||
|
||||
void apply() override;
|
||||
void reset() override;
|
||||
void updateUi() override;
|
||||
QUndoCommand *associatedUndo() const override;
|
||||
QString title() const override;
|
||||
bool setLiveEdit(bool live_edit) override;
|
||||
|
||||
private:
|
||||
void connectChangeSignals();
|
||||
void disconnectChangeSignals();
|
||||
|
||||
private:
|
||||
ConductorPropertiesWidget *m_cpw = nullptr;
|
||||
QCheckBox *m_apply_all_cb = nullptr;
|
||||
Conductor *m_conductor = nullptr;
|
||||
ConductorProperties m_initial;
|
||||
QList<QMetaObject::Connection> m_live_connections;
|
||||
bool m_updating = false;
|
||||
};
|
||||
|
||||
#endif // CONDUCTORPROPERTIESEDITORWIDGET_H
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "../../utils/qetsettings.h"
|
||||
#include "../../utils/qetutils.h"
|
||||
#include "../../qetmessagebox.h"
|
||||
#include "../../textgrid.h"
|
||||
#include "../nokde/kcolorbutton.h"
|
||||
#include <QFileDialog>
|
||||
#include <QFontDialog>
|
||||
@@ -66,8 +67,22 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
|
||||
|
||||
ui->grid_startup_cb->setChecked(settings.value("diagrameditor/grid_display_startup", true).toBool());
|
||||
ui->guides_startup_cb->setChecked(settings.value("diagrameditor/guides_display_startup", false).toBool());
|
||||
//Stored as "inserts" but presented as "edits", so the default (insert)
|
||||
//is the unchecked state -- a preference reads better as an opt-out.
|
||||
ui->m_collection_dblclick_edits->setChecked(!settings.value("elementscollection/double-click-inserts", true).toBool());
|
||||
ui->m_context_toolbar_cb->setChecked(settings.value("diagrameditor/context_toolbar", true).toBool());
|
||||
ui->m_mouse_gestures_cb->setChecked(settings.value("diagrameditor/mouse_gestures", 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());
|
||||
@@ -87,6 +102,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
|
||||
else
|
||||
ui->m_use_windows_mode_rb->setChecked(true);
|
||||
ui->m_zoom_out_beyond_folio->setChecked(settings.value("diagrameditor/zoom-out-beyond-of-folio", false).toBool());
|
||||
ui->m_conductor_properties_panel->setChecked(settings.value("diagrameditor/conductor_properties_panel", false).toBool());
|
||||
ui->m_use_gesture_trackpad->setChecked(settings.value("diagramview/gestures", false).toBool());
|
||||
ui->m_save_label_paste->setChecked(settings.value("diagramcommands/erase-label-on-copy", true).toBool());
|
||||
ui->m_enable_scripting->setChecked(QetSettings::scriptingEnabled());
|
||||
@@ -280,13 +296,18 @@ void GeneralConfigurationPage::applyConf()
|
||||
settings.setValue("diagrameditor/viewmode", view_mode) ;
|
||||
settings.setValue("diagrameditor/highlight-integrated-elements", ui->m_highlight_integrated_elements->isChecked());
|
||||
settings.setValue("diagrameditor/zoom-out-beyond-of-folio", ui->m_zoom_out_beyond_folio->isChecked());
|
||||
settings.setValue("diagrameditor/conductor_properties_panel", ui->m_conductor_properties_panel->isChecked());
|
||||
settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value());
|
||||
|
||||
settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked());
|
||||
settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked());
|
||||
settings.setValue("elementscollection/double-click-inserts", !ui->m_collection_dblclick_edits->isChecked());
|
||||
settings.setValue("diagrameditor/context_toolbar", ui->m_context_toolbar_cb->isChecked());
|
||||
settings.setValue("diagrameditor/mouse_gestures", ui->m_mouse_gestures_cb->isChecked());
|
||||
//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());
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="m_conductor_properties_panel">
|
||||
<property name="text">
|
||||
<string>Afficher les propriétés d'un conducteur sélectionné dans le panneau Propriétés de la sélection</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line_2">
|
||||
<property name="orientation">
|
||||
@@ -77,6 +84,36 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="m_collection_dblclick_edits">
|
||||
<property name="toolTip">
|
||||
<string>Par défaut, un double-clic insère l'élément sur le folio ; l'édition reste accessible par le menu contextuel.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Double-cliquer dans la collection ouvre l'éditeur d'élément au lieu de l'insérer</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="m_context_toolbar_cb">
|
||||
<property name="toolTip">
|
||||
<string>Après un clic qui sélectionne un élément ou un conducteur, les commandes de la barre de raccourcis apparaissent près du curseur et s'effacent quand la souris s'éloigne.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Afficher les commandes près de la sélection</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="m_mouse_gestures_cb">
|
||||
<property name="toolTip">
|
||||
<string>Maintenir le bouton droit et glisser dans une direction lance une commande de la barre de raccourcis. Un simple clic droit ouvre toujours le menu contextuel, au relâchement du bouton.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Gestes de la souris avec le bouton droit</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="guides_startup_cb">
|
||||
<property name="text">
|
||||
@@ -784,6 +821,26 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="Label_Diagram_textGrid">
|
||||
<property name="text">
|
||||
<string>Grille des textes déplacés à la souris</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Fraction de la grille des folios. Maintenir Ctrl pendant le déplacement pour placer librement.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="DiagramEditor_textGrid_cb">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
@@ -1155,6 +1212,7 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
|
||||
<tabstop>m_use_system_color_cb</tabstop>
|
||||
<tabstop>m_use_gesture_trackpad</tabstop>
|
||||
<tabstop>m_zoom_out_beyond_folio</tabstop>
|
||||
<tabstop>m_conductor_properties_panel</tabstop>
|
||||
<tabstop>m_use_windows_mode_rb</tabstop>
|
||||
<tabstop>m_use_tab_mode_rb</tabstop>
|
||||
<tabstop>m_save_label_paste</tabstop>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user