Compare commits

...

124 Commits

Author SHA1 Message Date
Laurent Trinques d5027eadbb Merge pull request #1076 from ispyisail/fix/dxf-titleblock-linebreak
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m25s
Fix title blocks with a line break making the DXF unreadable
2026-09-27 12:03:02 +02:00
Laurent Trinques da09206826 Merge pull request #1075 from ispyisail/fix/dxf-entity-order
Fix DXF export writing entities in a different order every time
2026-09-27 12:00:33 +02:00
Laurent Trinques 9812166071 Merge pull request #1073 from ispyisail/feat/1069-snap-to-grid
Add "Snap to grid" for selected symbols, pictures and texts
2026-09-27 11:55:49 +02:00
ispyisail 4efe28ce03 Fix title blocks with a line break writing an unreadable DXF
A title block cell holding a line break (tableau_domestique.qet's
"DESSINE" + CR LF + "%{author}") was written into the DXF as one TEXT
value with the break inside it. A DXF value ends at the line break, so
every group code after it was off by one line: ezdxf refuses all five
folios of that example with DXFStructureError, and other readers take
values for codes.

- TitleBlockTemplate::renderTextCellDxf() writes each line of a cell as
  its own TEXT, stacked by the cell's vertical alignment as the screen
  shows them, 1.6 text heights apart as the diagram texts' DXF export
  does. A single-line cell is written exactly as before.
- Createdxf's text writers turn any line break still in a value into a
  space, so no other caller can break the file either.

Checked in the Export dialog: tableau_domestique.qet's five folios, which
ezdxf refused on master, now read with no audit errors; grafcet.qet, which
has no line break, keeps exactly the same entities. (Byte comparison waits
on the DXF entity-order fix: until then every export reorders its entities.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 21:33:00 +13:00
ispyisail 7c4355338b Fix DXF export writing entities in a different order every time
Exporting the same folio to DXF twice gave files with the same entities
in a different order, so two exports never compared equal and a DXF could
not be diffed to spot a change. The export walked diagram->items(), whose
order follows memory addresses; saving had the same fault until
bugtracker #343.

Items are now taken in stacking order (z, then insertion order, which a
load makes the file's order), from the same rect query Diagram::toXml()
uses. Anything the query misses keeps its items() place after the rest.

Checked in the Export dialog on grafcet.qet, three folios, two exports
per build: master's differ every time, these are byte-identical, and both
contain exactly the same entities, only reordered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 21:28:36 +13:00
ispyisail 4c3e86c07e Add "Snap to grid" for the selected symbols, pictures and texts
Edit > Aligner > Aligner sur la grille, also in the selection's
context menu and the command search, puts each selected item back where
dragging it would have left it: symbols and pictures on the folio grid,
free texts on the text grid. One undo step; wires follow their symbols.

Symbols leave the grid through the fine nudge (Alt+arrow, 1 px), and
nothing put them back: 508 of the 3,678 symbols in the example projects
are off the 10 px grid. First stage of discussion #1069.

The status bar says how many items moved, or that the selection was
already on the grid, and how many locked items were left in place.
Shapes are left out: they are made of several points and no single one
is the obvious one to snap.

The snap never reads the keyboard, unlike Diagram::snapToGrid(), so a
user who binds the action to a shortcut with Ctrl gets the grid, not
pixel rounding. The geometry is in alignment.h, tested by tst_alignment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 21:00:18 +13:00
Laurent Trinques 44cabbcfe4 Merge pull request #1065 from ispyisail/feat/uuid-shapes-texts-images
Add a permanent id to shapes, texts, pictures and symbol parts
2026-09-27 09:55:25 +02:00
ispyisail a2a30ef7c4 Merge branch 'master' into feat/uuid-shapes-texts-images
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:51:23 +13:00
Laurent Trinques 0b4f480dfe Merge pull request #502 from ispyisail/proto-conductor-selection-properties
Add wire properties to the Selection properties panel, as an option (#500)
2026-09-27 09:11:22 +02:00
Laurent Trinques 05260de87b Merge pull request #1060 from ispyisail/feature/shortcut-bar-symbols
Add pinned elements to the shortcut bar, with a width you can set
2026-09-27 09:09:44 +02:00
Laurent Trinques db154a8d06 Merge pull request #1059 from ispyisail/fix/ctrl-shift-pan-stuck
Fix the folio staying in pan mode after Ctrl+Shift+M
2026-09-27 09:08:02 +02:00
Laurent Trinques 7dc1d345b0 Merge pull request #1057 from ispyisail/feature/mouse-gestures
Add right-drag mouse gestures to run commands
2026-09-27 09:02:44 +02:00
Laurent Trinques ba1811a0cf Merge pull request #1056 from ispyisail/feature/repeat-command
Add repeating the last drawing or placing command with Enter
2026-09-27 09:02:02 +02:00
Laurent Trinques a3dc8d3ad4 Merge pull request #1055 from ispyisail/feature/context-toolbar
Add a command bar beside the selection after a click
2026-09-27 09:00:29 +02:00
Laurent Trinques b1fe1ef7a7 Merge pull request #1054 from ispyisail/feature/shortcut-bar-customise
Add customising the shortcut bar in place
2026-09-27 08:59:55 +02:00
Laurent Trinques 9584fc4b7c Merge pull request #1053 from ispyisail/feature/shortcut-bar
Add a shortcut bar that opens at the cursor with S
2026-09-27 08:59:32 +02:00
Laurent Trinques 25baf48153 Merge pull request #1052 from ispyisail/feature/element-picker
Add an element picker that opens at the cursor with Insert
2026-09-27 08:58:50 +02:00
Laurent Trinques 7f51a3e10a Merge pull request #1051 from ispyisail/feature/ranked-search
Show collection search results as a ranked list
2026-09-27 08:47:30 +02:00
Laurent Trinques 503823bfce Merge pull request #1050 from ispyisail/feature/context-menu-placement
Add placing the last element and folio references to the folio menu
2026-09-27 08:47:00 +02:00
Laurent Trinques 30c97612a2 Merge pull request #1042 from ispyisail/feature/place-without-drag
Add double-click placement from the collection, and A to repeat it
2026-09-27 08:40:42 +02:00
Laurent Trinques 8cdf51405a Merge pull request #1066 from ispyisail/fix/readonly-query-all-rows
Fix table and script queries returning only their first row
2026-09-27 08:06:08 +02:00
Laurent Trinques 4a5755e552 Merge pull request #1068 from ispyisail/feat/349-image-label
Add a label to pictures (#349)
2026-09-27 08:04:44 +02:00
Laurent Trinques ef8b3866ba Merge pull request #1067 from ispyisail/perf/image-save-png-cache
Speed up saving projects that contain pictures
2026-09-27 08:04:29 +02:00
ispyisail f6f53e3f52 Stop re-encoding unchanged pictures on every save
DiagramImageItem::toXml() PNG-encoded every picture on every save,
autosave and copy, whether or not it had changed. That is most of the
cost of pictures in a project: resaving one holding 60 of them took
6.2 s and now takes 3.8 s.

The PNG bytes are now kept and reused while QPixmap::cacheKey() still
matches, so any edit (replace, crop, mirror, transparency) re-encodes
without each of those functions having to invalidate anything. On load
the cache is filled with the file's own bytes, so the first save
encodes nothing either.

Output is byte-identical to before on the example projects. A picture
whose PNG came from another encoder now keeps its original bytes
instead of being re-encoded; the pixels are identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 16:43:11 +13:00
ispyisail 5120feb417 Add an optional label to pictures (#349)
A picture can now carry a caption, set from its properties panel
("Libellé"). It is drawn centred under the picture at the folio's
normal text size whatever the picture's scale, turns with it, and
moves, copies and prints with it because the picture itself paints it.
Clicking the caption selects the picture.

Saved as a "label" attribute on <image>, written only when non-empty:
a project without labels saves byte-for-byte as before, and older
versions open a labelled project and simply ignore the caption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 16:12:51 +13:00
ispyisail cd38f6f2a2 qet-mcp: test the uuids given to drawn items in older files
A file written before drawn items carried a uuid gets them from the folio
uuid, the kind of item and its order in the file. Three integration tests
pin what that promises: opening the same file twice gives the same uuids,
the first save writes exactly those and a reopen keeps them, and a uuid
repeated in a hand-edited file ends up naming one item only.

Each fails when the derived uuid is replaced by a random one. They read
drawing_item_view unsorted, which is what found the first-row-only bug
in QETSql::execReadOnly() fixed by the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 14:00:16 +13:00
ispyisail 29fe731728 Fix read-only queries stopping after the first row
Since #1046, QETSql::execReadOnly() runs a query with PRAGMA query_only
set and switches it off before returning. Switching it off aborts a
statement SQLite is still stepping through ("abort due to ROLLBACK"), and
QSQLITE has already stepped to the first row by then. A query that
produces its rows as it goes -- a UNION ALL without ORDER BY -- therefore
came back with its first row only and no error. A sorted query was not
affected, because SQLite has read every row before returning the first.

Every query from the SQL box of a table, a saved <graphics_table> query
and the scripting qet.query() goes through here.

The checked run is now finished before query_only is switched off, and a
query that passed is run again for the caller. SQLite refuses a write at
its first step, so passing that step is what proves a statement reads
only; the second run is of a statement already shown to be read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 14:00:16 +13:00
ispyisail b740508b7f Fix read-only queries stopping after the first row
Since #1046, QETSql::execReadOnly() runs a query with PRAGMA query_only
set and switches it off before returning. Switching it off aborts a
statement SQLite is still stepping through ("abort due to ROLLBACK"), and
QSQLITE has already stepped to the first row by then. A query that
produces its rows as it goes -- a UNION ALL without ORDER BY -- therefore
came back with its first row only and no error. A sorted query was not
affected, because SQLite has read every row before returning the first.

Every query from the SQL box of a table, a saved <graphics_table> query
and the scripting qet.query() goes through here.

The checked run is now finished before query_only is switched off, and a
query that passed is run again for the caller. SQLite refuses a write at
its first step, so passing that step is what proves a statement reads
only; the second run is of a statement already shown to be read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 14:00:07 +13:00
ispyisail 76eea09192 qet-mcp: address texts, shapes and images by uuid; part uuids in symbols
- qet_edit: every op taking a text, shape or image "index" also takes its
  uuid, resolved at run time with textIndex()/shapeIndex()/imageIndex().
  Those are only required when a uuid is used.
- qet_element_build writes a uuid on every part (the caller's, or a new
  one) and returns them in part_uuids; qet_element_info lists part and
  terminal uuids.
- README and tests: drawing_item_view, uuid addressing, part uuids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:16:37 +13:00
ispyisail 30e83d8c52 Element editor: give every primitive a uuid
Lines, rectangles, ellipses, arcs, polygons, PLC tables and static texts
now save a uuid, as terminals and dynamic texts already did, so a tool can
name one part of a symbol. The graphic parts keep it next to their styles,
since all of them save through stylesToXml(); a part read from a
definition that predates this gets one on the next save.

Paste renews them, as it already did for terminals -- and now for dynamic
texts too, which were pasted with their source's uuid. A placed element
gives its dynamic texts fresh uuids anyway, so only the definition's own
identity changes. PartTerminal::uuid() returns the terminal's saved uuid
rather than inheriting the graphic-part one it never writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:16:37 +13:00
ispyisail 3a50cd9b0e Project database: tables for shapes, independent texts and images
Now that they carry a uuid, the folio's drawing furniture gets rows of its
own: shape, independent_text and image, plus drawing_item_view, which finds
any of them by uuid without knowing its kind first and says which folio it
is on.

Rows follow edits, not just loads. The script API's query() and every
other reader go through newQuery() without a rebuild, so an item's row is
queued on each change (moves, restyles, text edits, uuid renewal) and the
queue is flushed by newQuery() and updateDB() -- a queued write is a set
insertion, which matters for a drag that moves hundreds of items per mouse
step.

A pasted copy joins its folio still carrying its source's uuid and is only
renewed afterwards, so a flush in between must not let the copy overwrite
its source's row. Each row remembers the item that wrote it; another item
with the same uuid waits until uuidChanged() says it has its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:16:37 +13:00
ispyisail acb770909e DiagramPosition: compile the letter filter once
The constructor built QRegularExpression("[^A-Z]") on every call, and
compiling it cost more than the rest of Diagram::convertPosition() put
together -- about 95k instructions a call under callgrind. Every element
row of the project database pays it on each rebuild, as does anything
else that turns a scene point into a folio cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:16:37 +13:00
ispyisail a9ad6e2f34 Give shapes, independent texts and images a persisted uuid
Elements, conductors, terminals and tables already carry a uuid. The
drawing furniture beside them did not, so a script or the MCP server could
only name a line, a box or a note by its index in a position-sorted list,
which shifts whenever one is added or removed.

- QetShapeItem, IndependentTextItem and DiagramImageItem get uuid(),
  newUuid() and setUuid(), read from and written to a "uuid" attribute.
- A folio loaded from a file written before this (or carrying a duplicate
  uuid) derives one from the folio uuid, the item kind and its order in the
  file, so the same file gives the same uuids on every load and a re-save
  is stable -- the #754 lesson for conductors.
- Paste and folio duplication renew them, as they already do for elements
  and conductors.
- Scripting: texts(), shapes() and images() end each line with the uuid;
  textIndex(), shapeIndex() and imageIndex() turn one back into an index.
- misc/qet-mcp: qet_diff keys texts, shapes and images on uuid when both
  sides have one, so a move or edit reads as a change to that item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:54:54 +13:00
ispyisail ff795258c4 Conductor dock: turn it on in the preferences, off by default (#500)
Showing a selected conductor's properties in the selection-properties
dock is now a preference, on the General page under Appearance:
"Afficher les propriétés d'un conducteur sélectionné dans le panneau
Propriétés de la sélection". It is off by default, so selecting a
conductor does what it did before unless the user turns it on.

This replaces the View-menu toggle, which defaulted to on; that entry
is dropped in the merge with master. Same setting key
(diagrameditor/conductor_properties_panel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:13:55 +13:00
ispyisail 0cb424bd06 Merge remote-tracking branch 'upstream/master' into proto-conductor-selection-properties
# Conflicts:
#	cmake/qet_compilation_vars.cmake
#	lang/qet_en.ts
#	sources/qetdiagrameditor.cpp
2026-09-27 09:07:41 +13:00
ispyisail 47e5a8072b Shortcut bar: reopen at the cursor, wrap to a width the user sets
After customising, the bar now opens at the cursor like every other
time, instead of where the customising window was.

A size grip in the bar's corner sets its width. The tiles are laid out
in rows that wrap at that width, so a bar with many commands and pinned
elements becomes a block rather than one long strip; the height follows
the rows, and the width is saved. Until the user sets one, the tiles
stay on one row, as before. The grip is driven by hand: QSizeGrip asks
the window manager to resize, and on X11 a popup is not managed, so
nothing would happen. The hint line wraps rather than being cut off at
narrow widths.

The customising window remembers the size it was left at.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:16:56 +13:00
ispyisail 5bd55801e9 Pin elements to the shortcut bar
The bar's customising window gets an element search next to the command
list. Type part of a name, then drag a hit onto the bar, double-click
it, or press Enter to pin the best one. A pinned element shows as its
icon among the commands, and clicking it places the element, as the
picker does.

Pinned elements are saved in the same list as the commands, by
collection path (common://, custom://, company://), so the row keeps
the user's order. Elements embedded in a project are not offered: their
path names the project as loaded now. Elements are offered for the
empty-folio bar only; with something selected the bar is for acting on
it. Once any element is pinned there, the palette folder grid under the
bar is hidden, and comes back while typing a search.

Dragging a pinned element off the bar onto the commands, or a double
click, removes it. The Preferences page shows pinned elements by name
and icon, and removing one there drops it instead of listing it as a
command. The customising window is now kept on screen, since it is
taller than before.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:16:56 +13:00
ispyisail 5ccb089cdd Merge repeat-command into mouse-gestures (search results drag fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 03:00:26 +13:00
ispyisail 416ababc41 Merge context-toolbar into repeat-command (search results drag fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 01:59:59 +12:00
ispyisail 63771a41b5 Merge shortcut-bar-customise into context-toolbar (search results drag fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 01:59:59 +12:00
ispyisail 57905a5789 Merge shortcut-bar into shortcut-bar-customise (search results drag fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 01:59:59 +12:00
ispyisail 451cccd615 Merge element-picker into shortcut-bar (search results drag fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 01:59:59 +12:00
ispyisail af28a49b72 Merge ranked-search into element-picker (search results drag fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 01:59:59 +12:00
ispyisail 926bbcf75f Let search results be dragged onto a folio like the tree
While a collection search is active the tree is replaced by the flat
ranked list, and that list had no drag support: dragging a result only
moved the selection. Anyone used to searching and then dragging lost the
drag as soon as they typed.

The tree's drag is moved into a static ElementsTreeView::execElementDrag()
taking the source widget, and the results list uses it from the path each
row already carries, so the drag content and pixmap are the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 01:56:46 +12:00
ispyisail 6bcdb87524 Fix the folio staying in pan mode after a Ctrl+Shift shortcut
Holding Ctrl+Shift over a folio pans it, and only a key release seen by
the view ends that. A Ctrl+Shift shortcut that opens a window -- the
command search, Ctrl+Shift+M -- takes the keyboard before Ctrl and Shift
are released, so the view never sees the release. On Windows, where the
Shift press itself already reports Ctrl+Shift, the folio then stayed in
pan mode after a command was chosen: a hand cursor, and clicks ignored,
so a drawing tool picked from the search did nothing. On Linux the Enter
key's release happened to reach the view and end it.

The view now remembers that it pans because of Ctrl+Shift (not because
the hand tool was chosen) and stops when it loses the focus, or at a
click made without Ctrl+Shift, instead of waiting for a release it may
never get.

Reproduced on Linux by holding back Enter's release, as Windows does:
the line drawn after "Ctrl+Shift+M, une ligne, Enter" is not saved on
master and is with this change. Ctrl+Shift+drag and the hand tool still
pan (a dragged step does not move), as on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:35:56 +12:00
ispyisail f850d0bee8 Keep mouse gestures working while a tool is running
Most commands on the empty-folio ring start a drawing tool, and the view
left the right button alone while a tool ran, so the gesture after one
that started a tool only cancelled the tool: every other gesture opened
the context menu instead of the ring.

Now a right drag always shows the ring. A running tool is ended when the
drag starts, as picking another tool from the toolbar would. A plain
right click still goes to the tool, which cancels or finishes it as
before.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-27 01:34:55 +12:00
Laurent Trinques d7036ad807 Merge pull request #1046 from ispyisail/fix/1045-sqlite-handle
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m15s
Fix #1045: crash on clicking an element with online-installer Qt
2026-09-26 14:20:43 +02:00
Laurent Trinques 09fb81b99a Merge branch 'master' into fix/1045-sqlite-handle 2026-09-26 14:07:32 +02:00
ispyisail 501379def2 Merge repeat-command (with master) into mouse-gestures
# Conflicts:
#	cmake/qet_compilation_vars.cmake
2026-09-26 22:44:21 +12:00
ispyisail 52a831e1fa Merge context-toolbar (with master) into repeat-command
# Conflicts:
#	sources/qetdiagrameditor.cpp
2026-09-26 22:44:11 +12:00
ispyisail c1bb3b33f4 Merge shortcut-bar-customise (with master) into context-toolbar
# Conflicts:
#	cmake/qet_compilation_vars.cmake
2026-09-26 22:44:01 +12:00
ispyisail f91ec74dde Merge shortcut-bar (with master) into shortcut-bar-customise 2026-09-26 22:43:50 +12:00
ispyisail b08d0692b7 Merge element-picker (with master) into shortcut-bar
# Conflicts:
#	cmake/qet_compilation_vars.cmake
#	sources/qetdiagrameditor.cpp
2026-09-26 22:43:40 +12:00
ispyisail 627b818a30 Merge ranked-search (with master) into element-picker
# Conflicts:
#	sources/qetdiagrameditor.cpp
#	sources/qetdiagrameditor.h
2026-09-26 22:43:27 +12:00
ispyisail 0d2befbc00 Merge place-without-drag (with master) into context-menu-placement 2026-09-26 22:43:06 +12:00
ispyisail db387525e2 Merge place-without-drag (with master) into ranked-search 2026-09-26 22:43:06 +12:00
ispyisail 6763103d57 Merge master into place-without-drag (#1042)
# Conflicts:
#	sources/diagramview.h
#	sources/qetdiagrameditor.cpp
2026-09-26 22:39:52 +12:00
Laurent Trinques 852c32fc07 Merge pull request #1048 from ispyisail/feature/cell-lines
Add an option to show the folio cell limits across the drawing
2026-09-26 12:26:58 +02:00
Laurent Trinques e82548bee9 Merge pull request #1047 from ispyisail/fix/cell-ruler-ghosting
Fix the header bars: ghost labels on Windows, shown twice zoomed out
2026-09-26 12:21:24 +02:00
Laurent Trinques 60ab39b0f6 Merge pull request #1049 from ispyisail/feature/command-search-standalone
Add a command search: type part of a command's name, press Enter
2026-09-26 12:19:20 +02:00
ispyisail b57c37ca62 Run commands with right-drag mouse gestures
Hold the right button on a folio and drag: a ring appears around the
point where the button went down, showing up to eight commands, and the
one in the mouse's direction is highlighted with its name underneath.
Releasing runs it; releasing near the centre cancels. These are
SolidWorks' mouse gestures.

The commands are the shortcut bar's for the selection, placed clockwise
from the top, so customising the bar customises the ring. As with the
context menu, a right press selects what is under the mouse first.

A plain right click still opens the context menu, now on release on
every platform. The view tracks the right button and opens the menu
itself; the platform's own right-click event (sent on press on X11, on
release on Windows) is ignored while gestures are on, so the menu is
never opened twice. The keyboard menu is unchanged.

The view keeps out of the way while a tool or a placement is running,
since a right click cancels or finishes those, and while a text is
being edited. The new General option "Gestes de la souris avec le
bouton droit" (diagrameditor/mouse_gestures, on by default) turns it
off and restores the previous right button exactly.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit a625142f36e619bb4d4f3551b6086f2dd8eabc86)
2026-09-26 21:40:46 +12:00
ispyisail 329831d986 Repeat the last drawing or placing command with Enter
Enter on a folio runs the last drawing or placing command again, as it
does in SolidWorks: the last tool from the add-item family (text, line,
rectangle, terminal strip plan...) or the last element placed, however
it was placed -- dragged, double-clicked, picked, or from the shortcut
bar.

Enter is handled in DiagramView::keyPressEvent, not bound as a shortcut,
so it keeps working everywhere else: search fields, the collection
tree, dialogs. On the folio it is left alone while a tool is running or
an item has the focus (a text being edited), and with any modifier held.

The same command is in Édition, named after what it will do --
"Répéter : Ajouter une ligne", "Répéter : insérer « Diode »" -- and
registered with ShortcutManager (diagrameditor.repeat_last_command, no
default key), so it can be bound or put on the shortcut bar.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit 1e06ce97961ee18734ddaaa9b72b0303a169b9b2)
2026-09-26 21:40:32 +12:00
ispyisail b80d6e7cd3 Show the selection's commands beside the cursor after a click
After a click that selects something on a folio, a small row of commands
appears just above and to the right of the cursor, as the SolidWorks
context toolbar does. It fades as the mouse moves away and is gone past
200 pixels; a click elsewhere, the wheel, a key press or an emptied
selection hide it too. Clicking a command leaves it up, so rotate can be
clicked again.

The commands are the shortcut bar's for that selection -- elements or
conductors -- at most eight, so customising the bar customises this as
well. It is a child of the view's viewport, never a window, and never
takes the focus.

It is not shown after a drag (moving items, a rubber band), while
placing or drawing (Diagram::eventInterfaceIsRunning()), on
a read-only folio, or when switched off with the new General option
"Afficher les commandes près de la sélection" (diagrameditor/
context_toolbar, on by default).

ShortcutBarSettings::contextFor() now decides the context for both the
bar and this toolbar.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit 78f3c1a2bc5c3cf1f7beae4bd60aefec20847d6d)
2026-09-26 21:40:32 +12:00
ispyisail 6d42556d9f Customise the shortcut bar in place
Right-click the shortcut bar, or click the "…" button at its end, and it
turns into a small window holding two lists: the bar's commands, left
to right, and every other command. Drag a command onto the bar, off
it, or to another place on it; a double click moves it to the other
list. Terminé saves and shows the bar again where it was, with the
result; Annuler, Esc or closing the window leaves it as it was.
"Valeurs par défaut" puts back the defaults for this context.

A Qt::Popup closes on a press outside it and holds the mouse grab, so
the bar is re-shown as a Qt::Tool window for the time of the edit.
Only QSettings is written, through ShortcutBarSettings, never the
project's undo stack.

The popup now looks the commands up itself (popUpShortcutBar(pos,
context)) instead of being handed actions, since it has to rebuild
them after an edit. ShortcutBarSettings::availableIds() lists what can
go on the bar, shared with the configuration page. An empty bar still
shows its "…" button, so it can be filled again.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit 4145d4bec1d403126b2a6587105aea47092db8a2)
2026-09-26 21:40:19 +12:00
ispyisail 2da101ca90 Add a shortcut bar that opens at the cursor with S
Pressing S on a folio opens the element picker at the cursor with a row
of commands above it, chosen by what is selected, like the SolidWorks
shortcut bar:

  nothing selected     insert last element, element picker, text, line,
                       rectangle, terminal strip plan, paste, folio
                       properties
  elements selected    rotate, rotate texts, edit, copy, cut, delete
  only conductors      reset path, edit, delete

Each row is a list of ShortcutManager ids, so any registered command can
go on it and the bar carries no command list of its own. The lists are
in QSettings (diagrameditor/shortcut_bar/<context>); a context the user
has not changed follows the defaults. A disabled command keeps its
place, greyed, so a row looks the same each time. Clicking a button
closes the bar and triggers the action.

A new configuration page, "Barre de raccourcis", edits the three lists:
add, remove and reorder any diagram editor command.

To make that possible:
- ShortcutManager::action(id, owner) returns the action a given window
  registered under an id, since each editor window registers its own.
- The add-item actions (text, image, shapes, terminal strip plan) are
  registered as diagrameditor.add_<kind>, with no default key. They also
  appear in the Shortcuts page and can now be bound.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit 53c1e213282b9f7226ca1c09e81703113236bb88)
2026-09-26 21:40:19 +12:00
ispyisail 07501d604e Add an element picker that opens at the cursor
Insert opens a small picker where the mouse is, with its search field
focused. Type to search the whole collection, Enter to place the best
hit (it is preselected), Up/Down to choose another, Esc to close. The
chosen element goes into the usual placement mode, so it can be placed
several times and repeated with A.

With the field empty the picker shows a palette: the elements of a
folder, as an icon grid. The palette is a folder rather than a setting
or a file format. Subfolders are read in, the 01_/02_ filename prefixes
the shipped collection already uses give the order, and sharing it is
putting it in the company collection. Only the path is stored,
"elementscollection/palette-path", defaulting to the user collection.
It is read each time the picker opens, capped at 60 entries and three
folder levels.

The picker builds no second collection model. It asks the Collections
panel's rankedSearch(), so both give the same results in the same order
and startup is unchanged.

"Insérer un élément…" is in the Édition menu, registered with
ShortcutManager on Insert, which nothing else uses, and disabled with no
folio open or on a read-only project.

Discussion #676.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit 01e1e0303b96380e710da8d4bb2300b21d01e938)
2026-09-26 21:40:19 +12:00
ispyisail 73ad43bd8f Show collection search results as a ranked list
The Collections search hid non-matching rows, so the hits stayed spread
through the expanded folders. Searching "diode" against the shipped
collection left five levels of tree open, and the first visible hit was
"Avalanche diode bidirectional".

The same matches are now shown as a flat list that takes the tree's
place while a search is active, best match first:

  exact name            1000
  name starts with       800
  name contains          600, less the match position
  info field only        300
  less the name length, so "Diode" precedes "Diode Zener bidirectional"

"diode" now gives Diode, diode-tube, Diode bridge, Diodes Module Pilot
Wire, Photodiode. No new index: this ranks what match() already returns
against Qt::UserRole+1, the string built at startup from the name and
every element-info field. An element matching several "+" terms is
listed once, and each row's tooltip is its folder, so similar names can
be told apart.

Double click or Enter on a result places it, as in the tree. Down from
the search field moves into the results, so searching and placing needs
no mouse. Emptying the field brings the tree back.

The query and ranking live in rankedSearch(), which returns hits without
model indexes, so another list can reuse them.

Discussion #676.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit 3d6a280f9608758527ef0ca2de7223a19dfbfa45)
2026-09-26 21:40:19 +12:00
ispyisail c1d9e60656 Place the last element and folio references from the folio's context menu
Right-clicking an empty folio now also offers:

- "Insérer le dernier élément": the same action as Édition and the A
  key, shown once something has been placed.
- "Renvoi de folio": the folio report elements the project already
  uses, read from its embedded collection, with dated copies of the same
  element listed once. A project that has none yet gets the coming and
  going arrows of the common collection. An entry places its element
  where the menu was opened.

The submenu is rebuilt each time the menu opens, from the embedded
collection's XML, not from the folios' scenes. It is left empty, and so
hidden, on a read-only folio.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
(cherry picked from commit 94e3f61bf93689066b3964f2316e3c8f1ee7b673)
2026-09-26 21:40:18 +12:00
ispyisail 92cbc8fd2a Merge master into stage 2 (#1042) as the base of the stage PRs 2026-09-26 21:40:18 +12:00
ispyisail db56262924 Register the drawing tools so the command search finds them
Ajouter une ligne, un rectangle, une ellipse... had no ShortcutManager
id, so the command search could not list them. Give each one an id with
no default key; they can also be bound in the Shortcuts page now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:35:47 +12:00
ispyisail 86cb7e430e Add a command search: type part of a command's name, press Enter
Ctrl+Shift+M (Édition → "Rechercher une commande…") opens a small
search box at the cursor listing every command of the diagram editor
window, as SolidWorks' "Search Commands" and the command palette of
many editors do. Typing narrows it, best match first: name starting
with the text, then a word starting with it, then containing it.
Matching ignores case, accents and mnemonic "&", so "editer" finds
"Éditer l'item sélectionné". Each row shows the command's key when it
has one, which also teaches the keys. Disabled commands are listed,
greyed, and cannot be run. Enter runs the highlighted one after
closing the box; Esc closes.

The list is ShortcutManager's registry, restricted to the actions this
window owns (ShortcutManager::action(id, owner)), so a second editor
window's commands never appear and nothing has to be listed by hand.

Ctrl+Shift+P, the usual key for this, is already the autonumbering
dock's.

tst_commandsearch covers the folding, the ranking, that another
window's commands are left out and that a disabled command does not
run; both behaviours were checked to fail the test when broken.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 21:34:02 +12:00
ispyisail 3d260edcdb Cell rulers: line the top ruler up with the columns when the row header is hidden
BorderTitleBlock::draw() leaves the row header's room before the first
column even when the row header is hidden; insideBorderRect() does not,
so the top ruler was one header width out of line on such a folio. Take
the first cell's position from the header sizes, as draw() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:18:35 +12:00
ispyisail 53706a80d8 Add an option to draw the folio cell limits across the drawing
Affichage › "Afficher les limites des cases" draws a faint dashed line
at every column and row limit of the folio border, so that zoomed into
the middle of a folio you can see where a cell ends, not only which one
the headers name. Off by default, remembered once set
(diagrameditor/cell_lines).

Drawn by DiagramView::drawBackground(), under every item and only in
the view: printing and export render the Diagram, never the view, so
they cannot pick the lines up. PaletteGraphicsView gains scenePainter()
so a subclass can draw there on the inverted (dark palette) path too.

The lines follow the border's own cell positions, and each direction is
hidden when the folio hides that header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:17:19 +12:00
ispyisail 1e74bcd9de Cell rulers: no ghost labels on Windows, hidden while the folio's own header shows
The Windows 11 style gives QPalette::Button a translucent colour
(#FFFFFFB3). The rulers filled their background with it, and since they
paint with WA_OpaquePaintEvent nothing clears them first: every zoom
step blended the new labels over the old ones, leaving a fading trail.
Fill with the button colour composed over the window colour instead,
which is always opaque.

Each ruler is now also hidden while the folio's own column (or row)
header is wholly in sight, so zoomed out only the folio's headers show,
not both. When a ruler comes or goes the drawing keeps its place on
screen: the ruler covers or uncovers the edge of the viewport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:06:13 +12:00
ispyisail 45aec7735b Fix #1045: crash on selecting an element with the online-installer Qt
Since #983, projectDataBase::newQuery() checked a query with
sqlite3_prepare_v2() and sqlite3_stmt_readonly() on the handle of the
QSQLITE driver. Those calls go to the libsqlite3 QElectroTech links. The
QSQLITE plugin of the Qt online installer does not use that library: it
carries its own copy of SQLite, so the handle belongs to another library
and the call crashes. #1021 then put newQuery() on every element
selection, which is where #1045 hits it.

The check now runs the query with PRAGMA query_only set, through the
driver. SQLite refuses a write itself, before touching a row, so the CTE
prefix #983 closed ("WITH x AS (SELECT 1) DELETE FROM element") stays
closed. A refused or failed query comes back empty, because several
callers call exec() again on what newQuery() returns, after query_only
is off.

QElectroTech no longer calls the SQLite C API anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:06:07 +12:00
Laurent Trinques 923367d2a8 Merge pull request #1039 from ispyisail/fix/text-mover-null-drag
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m22s
Fix undo lost after a text drag that snaps back to its start
2026-09-26 08:44:04 +02:00
Laurent Trinques 6dd260a7b2 Merge pull request #1038 from ispyisail/feature/cell-rulers
Add an option to keep the folio row and column headers visible (#1034)
2026-09-26 08:43:42 +02:00
Laurent Trinques 132c564aef Merge pull request #1040 from ispyisail/feature/text-grid
Add a finer snap grid for dragged texts
2026-09-26 08:21:48 +02:00
Laurent Trinques e9c4969af7 Merge pull request #1044 from qelectrotech/revert-1037-german-translation
Revert "german translation qm file"
2026-09-26 08:06:56 +02:00
Laurent Trinques 040c4b5e29 Merge pull request #1041 from ispyisail/feature/context-menu-drawing
Add the drawing tools to the folio's right-click menu
2026-09-26 08:06:25 +02:00
Laurent Trinques 71d708e2da Merge pull request #1036 from ispyisail/feature/goto-cell
Add jumping to a folio cell such as B13 from Ctrl+G (#1034)
2026-09-26 08:04:17 +02:00
Laurent Trinques b490720652 Merge pull request #1035 from ispyisail/fix/spacemouse-capture-seconds-macos
Add macOS, Windows and --seconds to the 3D mouse recording script
2026-09-26 08:00:53 +02:00
Laurent Trinques 519f2245f6 macOS: fix arm64 bundle rejection (-10825) on macOS Sequoia
The Info.plist template shipped LSMinimumSystemVersion=12.3.0, but
the CMake build embedded the host SDK's deployment target (minos
26.0 on recent build machines) into the binary's LC_BUILD_VERSION.
Launch Services relies on the binary's minos, not the plist, so it
refused to launch the app on macOS < 26 even though it ran fine
when invoked directly.

- Set CMAKE_OSX_DEPLOYMENT_TARGET=14.0 explicitly in the cmake
  configure step, matching the real minimum imposed by the
  Homebrew Qt6 toolchain used for this build.
- Sync misc/Info.plist's LSMinimumSystemVersion to 14.0.0, and have
  MacQetDeploy_arm64_cmake.sh set it via PlistBuddy at bundle
  install time so it can't drift from the build target again.

Reported-by: guillaume.ruivo (forum)
2026-09-26 07:51:31 +02:00
ispyisail b61193f66f Revert "german translation qm file" 2026-09-26 17:50:02 +12:00
ispyisail e6291eb8e8 Place an element from the collection without dragging it
DiagramEventAddElement is already a good placement mode: the element
follows the cursor on the grid, a left click drops it, Space rotates it,
and it stays loaded for a run of the same symbol. Its only caller was
DiagramView::handleElementDrop(), so it could be reached only by
finishing a drag. Double-clicking a symbol in the Collections dock
opened the element editor instead.

- DiagramView::startElementPlacement() is split out of
  handleElementDrop(). defaultPlacementPos() uses the cursor when it is
  over the view and the centre of the visible area otherwise.

- ElementsCollectionWidget emits insertElementRequested() on double
  click, or Enter on the highlighted item. The host decides which view
  receives it, so the widget can later be reused outside the editor.

- When there is nowhere to place it (no folio open, read-only project),
  the element editor opens, as a double click did before.

- "Insérer le dernier élément" (Édition menu, default key A) places the
  last element again. DiagramView reports every placement it starts, so
  an element dropped by drag counts too. Macros are not remembered.

A rather than Space: Space rotates the pending element inside placement
mode and is bound three more times in this editor. The key is a
ShortcutManager default and can be changed in the Shortcuts page.

Double click placing is a behaviour change, so it has a preference,
"elementscollection/double-click-inserts" (default true), shown in
Configuration as an opt-out: "Double-cliquer dans la collection ouvre
l'éditeur d'élément au lieu de l'insérer".

Discussions #676 and #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 11:53:34 +12:00
ispyisail 0ca4b9f6c3 Put drawing first in the folio's context menu, rows and columns one level down
Right-clicking an empty folio offered Paste here, Folio properties and
four row/column actions. Four of six entries change the folio's layout,
which is rarely wanted and easy to hit by mistake, while nothing in the
menu helps draw.

The empty-folio menu now holds the "Ajouter" submenu (text, image,
shapes, terminal strip plan -- the same actions as the Edition menu and
toolbar), then Folio properties, then the row and column actions in a
"Lignes et colonnes" submenu. The selection menu is unchanged.

A submenu whose actions are all disabled is dropped, the same way
disabled actions already are.

Discussion #1033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 11:40:25 +12:00
ispyisail a08cdf7272 Reset the element text mover after a drag that ends where it started
ElementTextsMover::endMovement() returned early when the text had not
moved without clearing m_movement_running, so every later
beginMovement() on that folio was refused. The dragged text still
moves, because it positions itself, but the mover no longer tracks the
drag and builds no undo step for it.

To reproduce: Shift+drag one element text a couple of pixels so it
snaps back where it was, then Shift+drag another text of the element
and press Ctrl+Z. The second text stays where it was dropped; with this
change it goes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 10:37:21 +12:00
ispyisail 2fdb225936 Reset the element text mover after a drag that ends where it started
ElementTextsMover::endMovement() returned early when the text had not
moved without clearing m_movement_running, so every later
beginMovement() on that folio was refused. The dragged text still
moves, because it positions itself, but the mover no longer tracks the
drag and builds no undo step for it.

To reproduce: Shift+drag one element text a couple of pixels so it
snaps back where it was, then Shift+drag another text of the element
and press Ctrl+Z. The second text stays where it was dropped; with this
change it goes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 10:36:40 +12:00
ispyisail 95f3ea2f6d Share the text grid choices; add a preference, a status hint and a unit test
Drop 1:2.5: on a grid of 10 it steps by 4, which misses 10, so texts on
two elements 10 apart could never line up. Every remaining divisor is a
whole number, which tst_textgrid checks.

The divisor list and the snapping arithmetic move to the header-only
textgrid.h so the toolbar menu, the preferences page and the test share
them. The preferences page gets the same choice under Grille + Clavier;
QETApp::textGridChanged keeps every editor's toolbar button in step.
While element texts are dragged, the status bar names the text grid and
says to release Shift and hold Ctrl for free placement -- Ctrl+Shift
together is the pan shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 10:22:50 +12:00
ispyisail 0e02190eb2 Add a finer snap grid for dragged texts
Texts snapped to the folio grid, so the first drag of an off-grid label
pulled it sideways by up to half a grid step (discussion #1020). Texts
now snap to a fraction of the folio grid, chosen from a "Textes 1:N"
button in the View toolbar and a menu under Affichage: Off, 1:1, 1:2,
1:2.5, 1:5, 1:10. Because the step divides the folio grid, texts on
different elements still line up. Ctrl still places a text freely.

1:1 is the default and matches the previous behaviour. The setting is
stored in QSettings; nothing changes in saved projects.

All five text movers switch together: element texts, text groups,
texts moved with a selection, conductor texts and independent texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 09:15:35 +12:00
ispyisail 2807771200 Jump to a cell on another folio from Ctrl+G, typed as 3-B13
The popup now also takes a folio position before the cell, the way
cross references and folio reports write it by default (%f-%l%c): 3-B13
offers "Folio 3 (<title>), case B13", and Enter shows that folio and
zooms onto the cell. Case and a leading "p" are ignored, and the
separator is optional: 3-b13, 3b13, P3B13 and p3-b13 all work. A plain
B13 still means the current folio, and P3 alone is still row P,
column 3: a folio always needs a cell after it. A folio position past
the last folio, or a cell outside that folio's border, offers nothing.

The zoom on another folio is queued, so it runs once the tab switch
has been handled. Checked on the ATS example: jumping to 1-C5 from
folio 3 gives the same view, pixel for pixel, as C5 typed on folio 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 08:36:53 +12:00
ispyisail 4cffdf93b6 Merge pull request #1037 from Kellermorph/german-translation
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m15s
german translation qm file
2026-09-26 08:28:04 +12:00
ispyisail 9738345b45 Add an option to keep the folio row and column headers visible (#1034)
Affichage > "Garder les en-têtes visibles" adds a bar along the top and
the left of the diagram view that repeats the folio's column numbers and
row letters, aligned with the cells at any zoom, so they stay in sight
when the folio's own headers are scrolled away. Off by default; the
choice is stored as diagrameditor/cell_rulers.

The bars are CellRuler widgets in the view's margins
(setViewportMargins), not scene items, so printing and PDF/PNG/DXF
export never see them, and they paint with the application palette
outside of the dark-palette inversion. They keep a constant thickness;
when cells get narrower than their labels, only every 2nd, 5th, 10th...
label is written. A bar is hidden when the folio hides that header.
Showing or hiding them keeps the centre of the view where it was.

The labels come from BorderCellLabels, now also used by
BorderTitleBlock::draw(), so the bars and the border cannot disagree.
PNG export of all 133 folios of the examples is pixel-identical to
master, with border-columns_0 true and false.

Known limits: changing border-columns_0 repaints the bars at the next
scroll or zoom; the menu toggle updates the views of its own editor
window only, like the grid toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 08:22:12 +12:00
Kellermorph 286002fe25 german translation qm file 2026-09-25 21:48:49 +02:00
ispyisail ce0e7e6867 Add jumping to a folio cell such as B13 from Ctrl+G (#1034)
Typing a cell reference into the "Atteindre un élément" popup now offers
"Case B13"; Enter zooms the view onto that cell with one cell of margin.
The cell is read the way the border labels it: row letter(s) then column
number, honouring the "columns start at 0" setting and multi-letter rows
(AA, AB...). Cells outside the folio are not offered.

BorderTitleBlock::cellRect() is the reverse of convertPosition(); a
round trip over every cell of a 30x23 folio, under both column-numbering
settings, returned the same cell for all 1380.

When an element is labelled exactly like the cell (K1), the element stays
first so Enter keeps its old meaning; the cell is listed after it.

DiagramView::zoomToRect() re-centres from a queued call: zooming in makes
the scroll bars appear, and the viewport resize that follows is anchored
under the mouse (setResizeAnchor(AnchorUnderMouse)), which otherwise
scrolls the view away from the cell straight after the zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-26 07:37:38 +12:00
ispyisail 5f0b015186 Merge pull request #1031 from ispyisail/fix/bugtracker-240-backup-crash
Fix bugtracker #240: crash when a recovery file cannot be opened
2026-09-26 07:13:40 +12:00
ispyisail e5738c5b7d Merge pull request #1024 from arummler/fix-picture-insert
Fix picture and graph primitives issues
2026-09-26 07:13:20 +12:00
ispyisail c3c264cd78 Merge pull request #1010 from Kellermorph/full-contact-comb
Full contact comb
2026-09-26 07:12:57 +12:00
ispyisail d392ab4b8f Let misc/spacemouse-capture.py record on Windows, and say how on each system
Windows reads the device through hid.dll and SetupAPI with ctypes, as
hidapi's Windows backend does: shared, so 3DxWare can keep running, and
dropping the 0 report ID Windows adds, as hidapi does, so the bytes are
the ones QET decodes. Windows does not give out the report descriptor,
so a Windows recording has none; tst_spacemousehid then uses its
fallback layout.

The docstring, printed in full by --help, now has step-by-step
instructions for Linux, macOS and Windows, download included.

Tested: Windows Python 3.12 (embeddable) under Wine 10, against the
virtual uhid 3D mouse: --list finds it, a push sent during `right`
lands in `right`, and a button press and release land in `buttons`,
with the same bytes the Linux path records for the same push. Linux
and the mocked macOS path still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 06:50:33 +12:00
ispyisail ebd4a57563 Let misc/spacemouse-capture.py record on macOS
The recorder only read /dev/hidraw, so it could not run on a Mac. It now
also reads through IOKit via ctypes, using the calls hidapi's mac backend
makes: a shared open (as #1028 does) and the input report callback.
hidapi passes those bytes through unchanged, so a recording is exactly
what QET decodes on macOS. Standard library only, and no sudo.

If 3DxWare holds the device, or Input Monitoring is not granted, it says
which instead of recording nothing. The JSON adds "backend" and
"other_readers" (any 3DxWare or spacenavd process that was running).

Reports that queue up while it waits for Enter are now dropped, so each
step holds only its own movement. The Linux path is otherwise unchanged.

Tested: the Linux path against the virtual uhid device
(tools/hid-capture/fake-spacemouse.py in the docker harness). A stale
report was dropped and the step's own push was kept. The macOS path has
only been run against a mocked IOKit, not on a Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 06:44:08 +12:00
ispyisail 23d83ec153 Add --seconds to spacemouse-capture.py to record longer per step
The SpacePilot PRO recording on #599 has a weak `right` step: pushed
too lightly to read above the cap's normal cross-axis noise. A longer
window makes it easier to hold a firm push and gives the decoder more
samples to average over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 06:43:58 +12:00
Laurent Trinques d43c535cc9 Merge pull request #1030 from ispyisail/fix/bugtracker-343-save-order
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m2s
Fix bugtracker #343: saving reorders texts and shapes after an edit
2026-09-25 15:55:44 +02:00
Andre Rummler 71782aec20 Fix wheel scale hardening 2026-09-25 15:09:21 +02:00
Laurent Trinques 59dc59cf2f Merge pull request #1029 from ispyisail/fix/bugtracker-340-pdfxid
Fix bugtracker #340: small PDF text drawn too bold in Adobe Acrobat
2026-09-25 14:35:43 +02:00
Laurent Trinques 469c8cbc70 Merge pull request #1032 from ispyisail/feat/spacemouse-3dxware-macos
Fix 3D mouse doing nothing on macOS when 3DxWare is installed
2026-09-25 14:33:59 +02:00
Andre Rummler 1330dbb435 Fix janking behaviour during resize and Ctrl action. 2026-09-25 13:37:49 +02:00
ispyisail e7f19de2da Mention ConnexionBackend in SpaceMouseBackend's class comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:25:27 +12:00
ispyisail 4c9a307cb9 Say what realLibrary() can and cannot check
Measured on a macOS 15 runner with 3DxWare 10.8.13: an ad-hoc signed
test binary with the hardened runtime loads 3DconnexionClient with or
without the entitlement, so ad-hoc signing does not enforce library
validation and CI cannot prove the entitlement is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:07:34 +12:00
ispyisail 4381c6caa3 Read the 3D mouse through 3DxWare on macOS when it is installed
On macOS, 3DxWare installs a driver extension that takes the 3D mouse
over. With it installed, HidBackend opens the device but receives
nothing, so the mouse did nothing in QElectroTech until 3DxWare was
uninstalled (discussion #599, PR #1028). Most Mac owners of a 3D mouse
have 3DxWare installed.

ConnexionBackend asks 3DxWare for the motion instead, through
3DconnexionClient.framework, as Blender does. SpaceMouseListener tries
it first. When 3DxWare is not installed, or is installed but its driver
is not running, it falls back to HidBackend, so the device works in
both setups. Take-over mode stops 3DxWare's own actions in QET, so the
view does not move twice.

The library is loaded at run time from where 3DxWare installs it:
nothing is linked or bundled, and the build needs no SDK. The few
declarations are written here, from Blender's
GHOST_NDOFManagerCocoa.mm, because 3Dconnexion's SDK headers may not be
redistributed. 3DxWare's axes are y up and z away from the user; they
are mapped to QET's raw USB convention by comparing Blender's 3DxWare
and spacenavd code paths.

The release script signs with the hardened runtime, which refuses a
library another team signed. misc/qelectrotech.entitlements adds
com.apple.security.cs.disable-library-validation (Blender's notarized
build carries the same one), and MacQetDeploy_arm64_cmake.sh now passes
it to all four signings of the app, including the re-sign inside the
DMG.

Tested: tst_spacemouseconnexion runs the backend on every platform
against fakeconnexion, a stand-in library that answers from its own
thread as 3DxWare does: registration, the axis mapping, buttons, other
clients' messages, 3DxWare not installed or not running, deletion
with a message in flight. Flipping an axis sign or dropping the client
check turns it red. realLibrary() loads the real framework when
3DxWare is installed. Linux Qt 6 build with the 3D mouse enabled: all
18 tests pass.

Not tested: on a Mac with a real device. The axis signs and whether
buttons arrive as a bitmask with current 3DxWare are unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:54:07 +12:00
ispyisail 07d7aedc26 Fix bugtracker #240: crash when a recovery file cannot be opened
After a crash, QElectroTech offers to reopen its recovery files. If one
cannot be read, answering OK crashed the program instead of showing the
"could not open" warning: QETProject(KAutoSaveFile *) takes ownership of
the file and deletes it on failure, and openBackupFiles() then read the
file name from the deleted object to build the warning. Read the name
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-25 21:45:07 +12:00
Kellermorph 5622308ba2 Derive empty-slot labels from the group's declared terminals
Both issues from the review, preview of empty contact comb slots only:

- Slice the flat label list over the declared poles in drawAsContacts
  (terminals per pole from labels.size()/contactCount) instead of a
  fixed stride of 2 (3 for a switch) inside drawContact: terminalCount
  and contactCount are edited independently, so with 3 poles and the
  default terminal count of 2 the fixed stride starved every pole but
  the first. Available labels are now distributed across the poles.
- Map changeover labels of a slot to their own position always
  (common=stored[0] right, NC=stored[1] bottom-left, NO=stored[2]
  top-left, missing entries stay empty) instead of falling back to the
  raw stored order, which put the numbers on the wrong contact halves
  when the terminal count was below three.
- Element editor: changing the contact count now keeps the terminal
  count in step (same terminals per contact, type default 2/3 for
  inconsistent data, minimum 3 for a switch), so the mismatch cannot
  be created anymore; legacy mismatched data is handled by the new
  slicing.
- Drop the now redundant elmt check before is_power_ctc (it already
  includes it) - the dead null check from the review.
2026-09-25 11:24:35 +02:00
ispyisail 149ae961cc Fix bugtracker #343: saving reorders texts and shapes after an edit
Diagram::toXml() writes texts, images, shapes and tables in items()
order. The diagram scene uses NoIndex, and Qt's linear index sorts its
item list by pointer address the first time any item is removed from
the scene, which the editor does constantly (selection handles, for
one). From then on the order in the saved file follows memory
addresses, so moving one element reshuffles unrelated blocks and a
version-control diff of the project becomes unreadable.

Write those blocks in stacking order instead, read from a rect query,
which Qt sorts by z and insertion order even with NoIndex. Reloading a
file rebuilds exactly that order, so a resave is stable and the drawing
does not change. Elements and conductors were already sorted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-25 21:16:34 +12:00
ispyisail 4f309770f0 Fix bugtracker #340: small PDF text drawn too bold in Adobe Acrobat
Qt 6 declares the PDF/X namespace (pdfxid) in the XMP metadata of every
PDF it writes, even when the file is not PDF/X. Adobe Acrobat then draws
small text too bold at some zoom levels. Qt only needs the declaration
for PDF/X-4 output, which QElectroTech never asks for.

Blank the declaration out after export, in place with spaces so no
offsets move, in both the export window and the --export-pdf path.
Diagnosis and the Acrobat testing by Alf, bugtracker #340.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
2026-09-25 20:44:02 +12:00
Andre Rummler bb037ee3ff Fix: when pasting the picture or graphics object is nowunder the top left corner instead far away. 2026-09-25 00:24:15 +02:00
Andre Rummler d1256872c2 Fix transformation order. 2026-09-24 15:21:42 +02:00
Kellermorph 800189debb Address review: restore hover entries for empty position text
- Call updateLabel() explicitly when the xref is created in itemChange for a master that must show its configured contact groups without slaves (same pattern as the PLC branch above).
- Register the hover/click hit rect of a contact independently of its position text again, as before this feature: only the drawing stays guarded by !str.isEmpty(), and the map insert is now keyed on elmt so free slots (nullptr) never enter the map.
- Revert is_power_ctc to the original element-type test (with a null guard): the Power-flag term was redundant for every caller that passes an element, so no linked contact changes classification.
- Clarify the label-order comment: single pole NO/NC are swapped, changeover labels are rotated per pole inside drawContact() (multi pole included), multi pole NO/NC groups keep the master order.
2026-09-24 15:11:31 +02:00
Andre Rummler 522e008c26 Fix rotation of inserted picture. 2026-09-24 13:37:06 +02:00
Kellermorph c1cc9a5b98 Show all master-defined slaves in the contact comb behind a new option
Add a new cross reference setting per xref type (coil, protection,
commutator, PLC), labelled "Afficher tous les esclaves definis par le
maitre" and persisted as showallconfiguredslaves. When it is enabled,
the contacts display is selected and the master declares contact
groups, the contact comb draws every contact group of the master in the
master's own order, even when no slave is linked to it yet. Masters
without declared contact groups and the option turned off keep the
previous behaviour exactly: linked slaves only, sorted by position.

- XRefProperties: new property stored in the settings and in the
  project XML (attribute showallconfiguredslaves, absent means false so
  old files are unaffected), included in operator==.
- XRefPropertiesWidget: new checkbox placed after the terminal names
  one, enabled only while "Afficher en contacts" is selected; its
  enabled state is now also set explicitly when a type is loaded (a
  radio button that does not change emits no toggled()).
- For the PLC type the contacts/cross radios, the two display
  checkboxes and the cross options group are hidden: a PLC master is
  always drawn as its IO table, those settings have no effect there.
  Positioning and label settings, which the table really uses, stay.
- CrossRefItem: free slots draw the symbol of the group plus the
  terminal names the master defines (pairs swapped for a single pole
  NO/NC contact, labels of a changeover contact rotated one step
  counter-clockwise), without position text and without hover/click.
  Linked slaves keep drawing from their own data at their assigned
  group position; links without a group are appended at the end in
  position order.
- Xref lifecycle: the item is created and kept without linked slaves
  for snap-to-bottom (MasterElement::mustShowXrefWithoutSlave) and for
  snap-to-label (DynamicElementTextItem::updateXref and
  ElementTextItemGroup::updateXref, which now also run when the element
  lands on the scene and re-establish their project connection), so a
  freshly placed master shows its comb immediately instead of only
  after the next settings change. updateLabel() resets its geometry
  when the option is turned off again, so no stale ghost stays.
2026-09-24 09:59:21 +02:00
ispyisail 4e754cfac1 Conductor dock: sync to external property changes (#500)
The dock editor snapshotted the conductor once and never refreshed, so a
change made via the modal Edit-conductor dialog left the dock holding stale
values; on deselection apply() wrote that stale snapshot back, overwriting
the dialog's change. Subscribe to Conductor::propertiesChange and refresh via
updateUi() so the dock mirrors external edits and apply() becomes a no-op
when nothing changed in the dock.
2026-06-19 07:43:27 +12:00
Shane Ringrose 151b16ba76 Conductor dock: English translations + align checkbox wording (#500)
Add English translations (qet_en.ts) for the new conductor-panel strings
so non-French users don't see untranslated source text: the panel title,
the two undo captions, the apply-to-all checkbox, and the View-menu
toggle + status tip.

The apply-to-all checkbox now reuses the modal dialog's exact wording
("Appliquer les propriétés a l'ensemble des conducteurs de ce potentiel")
for consistency and so it shares the existing translation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:38:47 +12:00
Shane Ringrose 0902725a59 Conductor dock: apply-to-all-potential checkbox (#500)
Port the modal dialog's "apply to all conductors" option into the dock
panel, resolving the open propagation question. A persisted checkbox,
pinned above the tabs, makes each edit propagate to every conductor on
the same potential in one undo step - identical semantics to
ConductorPropertiesDialog, but the choice is remembered across edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:35:49 +12:00
Shane Ringrose 280051745a Conductor dock: apply edits live, edit via label, fill height (#500)
Addresses the testing feedback on the conductor selection-properties
prototype:

1. Edits were never applied. The dock drives every editor through
   setLiveEdit(true), which each editor overrides to connect its field
   changes to apply(); ConductorPropertiesEditorWidget didn't override it,
   so the call was a no-op. Implement setLiveEdit() to connect the hosted
   ConductorPropertiesWidget's controls (commit-style signals) to apply().
   A m_updating guard suppresses the signals emitted while the widget is
   loaded programmatically, so a partial mid-load state is never committed.

2. Clicking a conductor's text label showed nothing, because the label is
   a ConductorTextItem, not a Conductor. Map it to its parentConductor() in
   the editor factory, mirroring the double-click-the-label dialog behaviour.

3. The panel sat at a small size hint with empty space below it. Give the
   editor an Expanding vertical size policy (and a minimum height) so it
   fills the dock like the other editors.

Refs #500

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:35:49 +12:00
Shane Ringrose e3ede85efd conductor dock: scrollable/narrowable + View-menu enable toggle (#500) 2026-06-18 10:35:49 +12:00
Shane Ringrose 95c2f6cf6d proto: conductor properties in selection-properties dock (#500) 2026-06-18 10:35:48 +12:00
119 changed files with 9090 additions and 618 deletions
+12
View File
@@ -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()
+26
View File
@@ -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
+25
View File
@@ -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&apos;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&apos;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
View File
@@ -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>
+7 -1
View File
@@ -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..."
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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"},
},
+168 -5
View File
@@ -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="&lt;html&gt;&lt;body&gt;&lt;p&gt;{t}&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;"/>'
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
View File
@@ -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;
+49
View File
@@ -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
+51
View File
@@ -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
+46 -11
View File
@@ -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
+1
View File
@@ -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);
+192
View File
@@ -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));
}
+56
View File
@@ -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
+1
View File
@@ -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;
+236
View File
@@ -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);
}
+65
View File
@@ -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
View File
@@ -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
+431 -35
View File
@@ -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();
+30 -7
View File
@@ -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:
+55 -72
View File
@@ -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
+11 -11
View File
@@ -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
View File
@@ -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);
}
/**
+1
View File
@@ -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);
+12
View File
@@ -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)
+164
View File
@@ -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;
}
+60
View File
@@ -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
+69 -6
View File
@@ -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);
+35 -24
View File
@@ -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();
+30 -6
View File
@@ -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);
+25 -21
View File
@@ -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)
+1 -1
View File
@@ -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
};
+185
View File
@@ -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 &center,
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);
}
}
+60
View File
@@ -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 &center, 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
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+6
View File
@@ -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.
+5
View File
@@ -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);
+15
View File
@@ -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,
+23 -1
View File
@@ -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());
+3
View File
@@ -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;
};
+19 -1
View File
@@ -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) {
+5
View File
@@ -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);
+23
View File
@@ -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)
{
+9
View File
@@ -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;
};
+1
View File
@@ -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();
});
+6
View File
@@ -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
+4
View File
@@ -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;
+3
View File
@@ -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) {
+4
View File
@@ -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();
+412 -8
View File
@@ -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)));
}
+37 -1
View File
@@ -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);
+269 -53
View File
@@ -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;
}
+14 -1
View File
@@ -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;
+165 -15
View File
@@ -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
+1 -1
View File
@@ -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());
+11 -1
View File
@@ -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
+33
View File
@@ -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;
+1
View File
@@ -60,6 +60,7 @@ class MasterElement : public Element
private:
void xrefPropertiesChanged();
void aboutDeleteXref ();
bool mustShowXrefWithoutSlave() const;
void connectSlavePositionUpdates(Element *slave);
void disconnectSlavePositionUpdates(Element *slave);
+7 -1
View File
@@ -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"));
+7 -1
View File
@@ -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;
+47 -6
View File
@@ -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
+10 -8
View 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);
+208
View File
@@ -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);
}
+65
View File
@@ -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
+33
View File
@@ -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;
}
+2
View File
@@ -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;
+252
View File
@@ -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;
}
}
+81
View File
@@ -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
+3 -1
View File
@@ -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
{
+19 -2
View File
@@ -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) {
+62
View File
@@ -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
+48 -22
View File
@@ -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