Compare commits

..

130 Commits

Author SHA1 Message Date
Laurent Trinques d889a384aa Revert "fix: harden project database export" 2026-09-23 13:50:49 +02:00
Laurent Trinques ed0f800ab5 Update linux-build.yml
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m20s
2026-09-23 06:40:53 +02:00
Laurent Trinques 6d8459d647 Merge pull request #983 from ispyisail/fix-sql-readonly-cte-bypass
Enforce read-only SQL with SQLite, not a first-word check
2026-09-23 03:22:00 +02:00
ispyisail 28acbadaf0 Merge remote-tracking branch 'upstream/master' into pr983-rebase-check
# Conflicts:
#	sources/dataBase/projectdatabase.cpp
#	tests/qttest/CMakeLists.txt
2026-09-23 13:16:48 +12:00
Laurent Trinques 4456d08b91 Merge pull request #988 from elevatormind/chore/remove-obsolete-google-tests
Chore/remove obsolete google tests
2026-09-23 02:55:16 +02:00
Laurent Trinques aae7a8bde3 Merge pull request #980 from ispyisail/scripting-api-feature-complete
Scripting API: make it feature-complete, sync misc/qet-mcp
2026-09-23 02:47:25 +02:00
Laurent Trinques 4bcf5834e4 Merge pull request #984 from ispyisail/feature/scripting-opt-in
Scripting off by default, with a prompt and a setting to turn it on
2026-09-23 02:40:18 +02:00
Laurent Trinques f6889af99c Merge pull request #987 from elevatormind/fix/tests-pugixml-link
fix(tests): link pugixml to crash dump test
2026-09-23 02:34:54 +02:00
Laurent Trinques 9e5af6440b Merge pull request #989 from ispyisail/fix/titleblock-unset-variable-bug973
Fix bugtracker #973: unset title-block custom variable shows its own name
2026-09-23 02:33:56 +02:00
Laurent Trinques b97bccecd3 Merge pull request #990 from ispyisail/fix/diagramcontext-trims-whitespace-value
Preserve an all-whitespace context value through save and reload
2026-09-23 02:32:09 +02:00
Laurent Trinques 14cf3e403f Merge pull request #982 from Kellermorph/fix-copy
Fix copy
2026-09-23 02:22:11 +02:00
Laurent Trinques 1f0ed43006 Merge pull request #985 from ispyisail/fix/query-row-cap
A query stored in a project file can hang QElectroTech for ever
2026-09-23 02:19:28 +02:00
Laurent Trinques fc2a200ca2 Merge pull request #993 from ispyisail/feature/duplicate-offset-dialog-991
Add Ctrl+D: duplicate the selection, offset by a configured grid step
2026-09-23 02:17:36 +02:00
Laurent Trinques ccf96e0537 Merge pull request #986 from elevatormind/no-sqlite
fix: harden project database export
2026-09-23 02:15:29 +02:00
ispyisail fa213d90d9 Add Ctrl+D: duplicate the selection, offset by a configured grid step (#991)
Split from #913's second suggestion. There was no shortcut for the common
"duplicate with offset" convention; the nearest existing feature,
"Collage multiple", is a different workflow (a dialog for repeating a
paste in a grid pattern, not a one-shot duplicate).

Ctrl+D copies the selection and places it immediately, offset by a
configured spacing and direction -- no interactive follow-the-cursor
step, unlike Ctrl+V. The first press (or after the setting is explicitly
reopened) shows DuplicateOffsetDialog: spacing in grid steps, direction
up/down/left/right. Every later press reuses whatever was confirmed then,
silently, so a row of copies is one key held down and tapped, not a
dialog every time -- unattended, repeatable stamping is the actual point
of a duplicate shortcut, which a dialog or an interactive placement step
on every press would defeat. A separate "Configurer la duplication..."
entry reopens the dialog on demand to change the setting later. Cancel
leaves the diagram untouched -- verified, not assumed: qet_diff against
the saved file shows 0 added.

Chaining ("keep tapping to lay out a row") needs no special handling:
QET already reselects whatever a paste just added
(PasteDiagramCommand::redo()), so the next Ctrl+D naturally continues
from the copy just placed rather than the original.

The offset is applied by hand rather than by asking paste()/fromXml() to
place the copy at a target position. Both of those feed the position
through Diagram::snapToGrid(), which reads
QApplication::keyboardModifiers() and rounds to the nearest PIXEL instead
of the grid whenever Ctrl is held -- and Ctrl is always held here, this
action's own shortcut being Ctrl+D. Measured before settling on this:
routing the offset through paste() first produced copies off-grid on both
axes, by an amount that tracked the selection's own bounding-box geometry
rather than being a fixed error -- caught by qet-mcp's qet_elements
against the saved file, not by eye. fromXml() is instead called with no
position argument at all (leaves every item at its source coordinates,
landing the copy on top of the originals -- (0,0) is not a position, this
is "keep the source coordinates"), and the offset is added directly with
setPos(). A plain addition cannot be off by a rounding rule that never
runs.

Conductors are not in the hand-translated set: fromXml() itself does not
reposition them either -- they load after elements are already in their
final place and take their geometry from their terminals, which have
already moved with the elements that own them. Verified this holds: drew
a conductor by hand between two elements (drag, not click-click),
selected both, Ctrl+D, and the new conductor correctly joins the two new
elements via qet_conductors -- not the originals, not a mix.

Verified end-to-end on a built binary via qet-mcp, not by eye:

  before          L2 (303,207)  L9 (512,196)     -- deliberately off-grid
  spacing=2, down    (303,227)     (512,216)      -- +0,+20 exactly
  same again, 2nd    (303,247)     (512,236)      -- +0,+20 again, chained

Both elements land exactly the configured offset from their immediate
source regardless of the selection's own alignment. Qt 6.10.2, ctest
11/11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:30:30 +12:00
ispyisail 84add7b3ef Preserve an all-whitespace context value through save and reload
A property value that is entirely whitespace -- reported in #973 as a
workaround (setting a title-block custom variable to a single space, the
only way to give it a value other than blank before that bug was fixed in
#989) -- did not survive a save/reload cycle. Two independent causes, both
needed for the round trip to actually work:

1. DiagramContext::toXml() called .trimmed() on every stored value before
   writing it, unconditionally. For ordinary content this only strips
   accidental leading/trailing whitespace, but for a value that IS
   whitespace it collapses the entire thing to "", indistinguishable from
   a value that was never set.

2. QDomDocument::setContent(), used to parse the project file, discards a
   text node that is entirely whitespace by default. Confirmed in
   isolation, outside any QET code: parsing "<a> </a>" with the default
   ParseOptions gives QDomElement::text() == ""; adding
   ParseOption::PreserveSpacingOnlyNodes gives " ". So even once (1) stops
   destroying the value on save, the very next load throws it away again.

Fix (1) only trims when the trimmed result isn't empty, i.e. leaves an
all-whitespace value untouched. Fix (2) adds PreserveSpacingOnlyNodes to
the one setContent() call that parses a project file
(QETProject::readProjectXml()) -- not the other ~19 call sites in the
codebase (clipboard paste, element/macro loading, translations, autonum
context), which read different, narrower documents and are not implicated
in this report.

Blast radius of (2): every place that walks a QDomNode's children already
filters on isElement() (see QET::findInDomElement()), so the extra
whitespace-only text-node siblings this keeps around are inert wherever
current code already expected only elements. The one place it isn't inert
is exactly the bug -- calling .text() on an element whose entire content
is whitespace.

Verified end-to-end, not just at one stage: a single-space title-block
variable now survives two successive --resave cycles unchanged (confirmed
byte-for-byte in the saved XML), and renders as blank space rather than
literal placeholder text or a vanished value. Re-saved all 24 shipped
examples with and without this change and diffed: 23 byte-identical, the
one that differs (schema_indus.qet) differs only in element uuids -- and
resaving it twice with the SAME unpatched binary produces that same kind
of diff, confirming it is pre-existing non-determinism in files that
predate persisted uuids, unrelated to this change. Qt 6.10.2, ctest 11/11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:20:31 +12:00
ispyisail 36fb048a80 Fix bugtracker #973: unset title-block custom variable shows its own name
BorderTitleBlock::updateDiagramContextForTitleBlock() skips merging a
page-level custom variable into the title block's render context whenever
its value is empty -- added by PR #572 to fix #531, where an empty
page-level value was shadowing a real project-level one of the same name.

But skipping the merge removes the key from the context entirely, and
TitleBlockTemplate::interpreteVariables() only replaces "%name"/"%{name}"
when "name" is an actual key in that context -- anything absent is left as
its own literal placeholder text. Folio Properties auto-adds every one of
a template's custom variables to the Custom tab with an empty value (#271/
#495) precisely so the user only has to fill in what's missing; until they
do, that variable now renders as e.g. "%label1" instead of blank.

Reproduced two ways: a synthetic fixture, and examples/2612_ats_singlephase.qet
itself, which already carries three such auto-added-but-unset properties
("label1", "label2", "label3") and renders all three literally on current
master.

Fix: skip the empty page-level value only when a project-level one already
exists to show through (preserving #531's guarantee); otherwise still merge
it in empty, so the placeholder resolves to blank rather than falling out
of the context altogether.

Verified against the shipped example (--export-png, before/after crop of
the rendered title block): "%label1"/"%label2"/"%label3" now blank. A
variable never added to the Custom tab at all ("%client", also present in
the same example) is unaffected -- nothing was ever configured for it, and
that is a separate, narrower case. Qt 6.10.2, ctest 11/11.

A related but distinct issue -- DiagramContext::toXml() trims a stored
value before saving, so an all-whitespace value is written as empty --
explains a second symptom from the same report (a single-space "workaround"
value vanishing after the project is reopened) but touches every
context-backed property, not just title blocks, and is left for a separate
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:08:21 +12:00
Magnus Hellströmer a7ae17a1b4 chore: remove obsolete Google test projects 2026-09-22 20:34:40 +02:00
Magnus Hellströmer a1675d7dea fix(tests): link pugixml to crash dump test 2026-09-22 20:02:01 +02:00
Magnus Hellströmer 13a37e1f59 fix: harden project database export
Use a bound VACUUM INTO path and remove the stale SQLite
handle declaration. Fix shell continuations in Windows CI and
Debian installation instructions.
2026-09-22 19:05:05 +02:00
Laurent Trinques 6fc7e4a090 Merge pull request #965 from elevatormind/no-sqlite
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m54s
Remove SQLite library dependency while retaining project db export
2026-09-22 18:51:00 +02:00
ispyisail 08b83f23e3 qet-mcp: say what to do when QElectroTech refuses to run scripts
#984 switches JavaScript scripting off by default, and five tools here
drive QElectroTech through --run: qet_query, qet_continuity, qet_check,
qet_project_new, qet_edit. Against such a build they all stop working, and
what came back was exit code 3 and a paragraph of French naming a settings
dialog nobody driving an MCP server is looking at.

Nothing needed building to make them work again -- _run_qet() inherits its
environment, so QET_ENABLE_SCRIPTING=1 in the "env" block of the client's
own configuration already reaches QElectroTech. Verified both ways against
a #984 binary: without it qet_query returns ok=false exit=3, with it
ok=true and the rows.

So this is about saying so. The refusal is now recognised and answered with
an instruction the caller can act on, keyed on QElectroTech naming the
variable with exit 3 as a fallback for a future build that words it
differently. Two older hints fitted the same symptom and were overwriting
it -- "the binary never ran the script ... is it a build with --run
support?" sends the reader to check the one thing that is fine -- so both
now yield to whatever the launch already reported. qet_check builds its
answer fresh rather than layering onto the launch result, so it carries the
reason across explicitly; without that every check read "no result came
back", which is true and tells nobody why.

The server does not set the variable itself, on purpose. A switch a program
turns on for itself is not a switch: whoever configured this server and
pointed it at a QElectroTech binary made that choice, and their interactive
QElectroTech keeps whatever its own setting says. README says this, and the
registration example now shows the env block with both variables in it.

Six tests, faking subprocess.run so they cost no launch. Two are structural
rather than behavioural: one fails if either older hint goes back to
assigning over the specific one, the other reads which tools actually pass
script= to _run_qet and fails if the hint's list of them drifts. Both were
mutation-checked by reintroducing exactly those mistakes.

176 tests pass with QET_BINARY, QET_ELEMENTS, QET_EXAMPLES and
QET_ENABLE_SCRIPTING set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 04:35:46 +12:00
ispyisail 3fa5e0a475 Stop a query in a project file from hanging QElectroTech for ever
A <graphics_table>'s <query> is stored in the .qet and executed when the
project loads. SQLite produces rows lazily, so the cost of that query is
not bounded by anything the project contains -- it is bounded by how long
the loop reading the rows is willing to run. A recursive CTE takes one line
to make that forever:

  WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) SELECT n ...

Put that in the <query> of any project's summary table and opening the file
pins a core at 100% and grows ProjectDBModel::m_record until memory runs
out. Measured on examples/industrial.qet with the query swapped, built from
master:

  clean     --export-bom   3.6 s, 396 rows, exit 0
  poisoned  --export-bom   killed at 90 s, still going, no output

No scripting, no MCP, no flag beyond an ordinary export. Opening the file in
the editor is the same code path.

QetScriptApi::query() has the identical loop, and the script engine's own
30 s interrupt does not reach it: that aborts JavaScript, and this is C++
inside a single call. Left alone it hung a --run for 45 s until the harness
killed it.

Both loops now stop at projectDataBase::MaxResultRows (100000) and say so.
That is a backstop, not a page size: the largest table in the shipped
examples is 396 rows, and a caller that reaches 100000 has been handed
something it should not run to completion. It is not silent either way --
the model logs the offending query text, and qet.query() sets queryError(),
so a truncated result is never mistaken for a complete one.

  clean     --export-bom   3.6 s, 396 rows, exit 0   (unchanged)
  poisoned  --export-bom   20.2 s, 396 rows, exit 0, warning names the query
  qet.query(recursive CTE) 3.8 s, 100000 rows, queryError() set

Reverting each cap restores the hang, so both checks discriminate.

Related to #983, which fixes a different flaw reachable through the same
stored query. Neither depends on the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 04:16:21 +12:00
ispyisail 8334a9a27f Scripting is off until asked for, and says how to turn it on
A script reaches the whole project and, through the export calls, the
filesystem. That is a capability most people installing an electrical CAD
program never asked for, and leaving it on by default hands it to them
anyway. So QET_HAS_SCRIPTING builds now ship with it switched off.

QetSettings::scriptingEnabled() is the single answer, read by all three
places that need it, with QET_ENABLE_SCRIPTING=1 overriding the stored
value. The override is not decoration: a CI job or a batch run has no
dialog to tick, and a machine whose HOME is created fresh for each run has
nowhere to keep the setting either. It beats a stored "false" on purpose,
so a box unticked once cannot lock a build server out of --run for good.
Only the exact value "1" counts.

  --run          refuses with exit 3 and a message naming both ways in.
  Projet > Exécuter un script...  asks once, and turns the setting on if
                 the answer is yes. Asking beats grey: a disabled menu
                 entry says something exists and nothing about how to have
                 it, and this is the pattern people already know from
                 macro security in office software.
  Configurer QElectroTech > Général > Projets  has the checkbox, for
                 turning it back off. While the environment forces
                 scripting on, the box is disabled and says why, and
                 applyConf() then leaves the stored value alone rather
                 than quietly overwriting it.

runOnProject() checks as well, after both callers have. It is the one
function that actually evaluates JavaScript, so it is the one place a
future caller cannot forget to ask; the callers check first only to give a
better answer than it can.

Verified on the built binary, all four states, with an isolated HOME:

  stored   env  result
  absent    -   refused, exit 3
  true      -   script runs, exit 0
  false     -   refused, exit 3
  false     1   script runs, exit 0

tst_scriptingsetting covers the same matrix hermetically, in its own
QSettings scope, and was mutation-checked: flipping the default to true
turns defaultsToOff() red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 04:06:33 +12:00
ispyisail 4a636b14e3 qet-mcp: confine tool paths to a workspace, and never clobber silently
Two security reviews of #980 landed on the same gap: every path in a tool
call is chosen by the model, and nothing checked where those paths pointed.
That made the server a read/write primitive for anything the process could
reach -- read any project on the disk, export one somewhere else, overwrite
an unrelated file, embed an arbitrary local image or PDF. The sandboxed HOME
each QElectroTech launch gets isolates settings, not the filesystem.

Data paths are now confined to a workspace: QET_MCP_WORKSPACE (os.pathsep
separated), defaulting to the directory the server was started in, which is
what an MCP host normally sets anyway. QET_MCP_ALLOW_ANY_PATH=1 turns the
check off; it exists so that is a visible choice rather than the default.
Paths are resolved before comparison, so a symlink planted inside the
workspace is judged by where it points -- the case a string-prefix check
gets wrong.

Two arguments are deliberately exempt: "binary" and "elements_dir". Those
are configuration, chosen once by whoever runs the server, and both normally
live in /usr or a build tree. Confining them would reject the ordinary case
while stopping nothing -- they are not where a model gets to point the
server at /etc.

Enforcement sits at the dispatcher, where model-supplied arguments enter,
not inside each tool. Importing the module and calling tool_export() from
Python stays unconfined and is meant to: that is the caller's own code with
the caller's own paths.

Separately, an existing "output" is now refused unless the call passes
"overwrite": true. qet_project_new already worked this way; qet_export,
qet_edit and qet_element_build now match it. Replacing a file is the one
step this server cannot undo.

17 tests cover it, including the symlink escape, the traversal, the
overwrite gate and the operation-level file paths that add_image and
add_pdf_page carry one level down. Two of them compare the policy table
against the tool schemas, because a write tool missing from either list
fails silently in opposite directions. Two more drive a real server process
over stdio, which is the only thing that shows a call is gated rather than
merely gate-able. Mutation-checked: removing the confinement fails 8, and
desynchronising the two lists fails the drift pair.

170 tests pass, with QET_BINARY, QET_ELEMENTS and QET_EXAMPLES set so none
are skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 03:36:29 +12:00
ispyisail f777be05b4 Enforce read-only SQL with SQLite, not with a first-word check
projectDataBase::isReadOnlySelect() decides whether a query only reads
by looking at its first keyword and rejecting internal semicolons.
SQLite has allowed a CTE prefix in front of a data-modifying statement
since 3.8.3, so

    WITH x AS (SELECT 1) DELETE FROM element

begins with WITH, contains no semicolon, passes the check, and deletes
every row. UPDATE and INSERT go through the same way.

This is not only reachable from the custom-query box. ProjectDBModel::
fromXml() reads a <graphics_table>'s saved <query> straight out of the
.qet and fillValue() executes it, so a project file can carry the
statement. Reproduced against a build of this branch's parent, with no
scripting and no CLI flag beyond the export itself: a project whose
stored table query was replaced with the DELETE above exported a bill
of materials of 0 rows instead of 14, exit code 0, nothing logged. A
silently empty or -- with UPDATE -- silently altered BOM is the kind of
output someone orders parts from.

Fixed by asking SQLite about the statement it actually compiled.
sqlite3_prepare_v2() compiles without running, sqlite3_stmt_readonly()
reports on the compiled statement rather than on how it was spelled,
and the prepare tail catches a second statement structurally. The same
project now exports its 14 rows again and logs a reason for the
refusal, while an ordinary WITH ... SELECT in a project file still runs
untouched -- the fix is not "ban CTEs".

isReadOnlySelect() stays in front of it rather than being replaced:
SQLite considers ATTACH, BEGIN and several PRAGMAs read-only too, since
none of them change the contents of the database, so dropping the
statement-type allowlist would have widened what is accepted while
fixing what is executed.

The check lives in its own translation unit depending on nothing but
QString and SQLite, so tests/qttest/tst_sqlreadonly.cpp can link it
alone and exercise the security property without standing up a
QETProject: 18 assertions covering the three CTE-prefixed writes named
in the review, bare writes, trailing statements, comment-only input
(which compiles to a null statement sqlite3_stmt_readonly() must not be
handed) and a null connection (refused, not waved through). Confirmed
the suite discriminates by deliberately disabling the new check and
watching exactly the nine write-refusal assertions go red while the
accept cases stayed green.

ctest 13/13, qet-coherence-check and qet-pdflink-check clean on the
example corpus.

Reported in PR #980's review thread by @elevatormind and confirmed
against this code by @scorpio810; fixed here on its own because the
flaw is in already-released code and needs none of that branch to
reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 03:23:40 +12:00
Kellermorph 8ee3f90047 Fix multi-second Ctrl+V stall and cursor jump on paste
Two issues on the interactive paste path:

1. Stall: DiagramEventAddPaste's constructor called Diagram::fromXml()
   with no database batching, so every addItem() emitted dataBaseUpdated()
   and each connected table model re-ran its full SQL query. A typical
   paste (~40 elements + ~40 conductors) triggered ~77 rebuilds of the
   table models -- measured at ~2.1 s of pure fromXml time on a large
   project. Project loading already batches this via
   setUpdateBlocked()/blockSignals() (QETProject::readProjectXml); the
   paste path now does the same: block during fromXml, one updateDB()
   after. Measured fromXml: 2114 ms -> 143 ms.

2. Cursor jump: m_initial_cursor was set to the group origin but the
   physical cursor stayed at the Ctrl+V press location, so the first
   mouseMoveEvent computed a large delta and the items jumped on first
   touch. Warp the cursor to the group origin after placement so the
   baseline and the actual cursor position match.
2026-09-22 15:38:22 +02:00
Kellermorph 41e1e8172c Keep paste at original XML position instead of moving to cursor
Commit dd0c194a3 (#913) moved the pasted group to the cursor position
at construction time. The desired behaviour is that items appear at
their original XML coordinates (where they were copied from) so the
user starts from the origin. The grid-snapped movement baseline and
the context-menu restoration from that commit are kept.
2026-09-22 15:03:10 +02:00
Laurent Trinques 1212f48c6d Merge pull request #979 from Kellermorph/colour-programm
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m48s
Add custom application color picker in global settings
2026-09-22 13:49:42 +02:00
Laurent Trinques e01f46c5b0 Merge pull request #981 from ispyisail/fix-974-report-link-colour
Fix report-link colour/style mismatch detection (#974)
2026-09-22 13:38:40 +02:00
ispyisail 3cec02b3f3 Fix report-link colour/style mismatch detection (bugtracker #974)
LinkElementCommand::redo() already had a check meant to catch exactly
this -- two report-linked conductors whose properties disagree -- and
ask the user which to keep via PotentialSelectorDialog. It never
worked: it built ONE combined list from three unrelated fields
(tension_protocol, wire_color, wire_section) and tested that whole
list for string equality, so a tension-protocol value could never
equal a wire-colour value even when every field individually matched
across every conductor. Worse, "wire_color"/"wire_section" are
ConductorProperties::m_wire_color/m_wire_section, a separate free-text
documentation pair that says nothing about how the wire is actually
drawn -- that's "color"/"style" -- so the one field #974 is actually
about was never compared at all.

Fixed by comparing each relevant field (text/num, function, tension
protocol, colour, line style) separately. Downloaded the reporter's
actual project, confirmed the mismatched wire reads color="#0000ff" on
one side of a "Folio suivant"/"Folio precedent" link and
color="#55aa00" on the other, with the link's other four conductors
matching correctly (ruling out a rendering artifact) -- see PR #980's
checkContinuity() extension, which now flags this class of mismatch on
sight.

Extracted the comparison into its own static
reportLinkNeedsPotentialChoice(), for the same reason
ConductorCreator::needsPotentialChoice() already exists as its own
method: a caller with nobody there to answer a modal dialog needs to
check first and decline, and the condition must not drift away from
the one redo() actually applies.

Fixing the comparison surfaced a real, previously-latent hang in this
session's own qet.linkElements(): PotentialSelectorDialog::exec() is a
plain QDialog::exec(), not routed through QET::QetMessageBox, so
headless --run has nobody to answer it. Measured directly -- hung
until killed with the property-comparison fix alone, clean refusal
after adding the guard. linkElements() now calls
reportLinkNeedsPotentialChoice() before constructing the command and
declines with a clear reason, the same choice addConductor() already
makes about ConductorCreator's own equivalent dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:00:05 +12:00
ispyisail a2e384761c Sync misc/qet-mcp: report-link tests updated for the #974 fix
link_elements() now correctly refuses a mismatched report link instead
of allowing it (see the two preceding commits), so the
report_link_mismatch reproduction can no longer be built by linking
two already-differently-coloured conductors -- that path is refused
before it happens. Updated to patch a saved file's XML directly
instead (the same technique test_continuity_detects_a_tampered_
potential already uses), since a file QElectroTech's own edits produce
can no longer end up in this state at all. Adds
test_link_elements_refuses_a_mismatched_report_link to cover the
refusal itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:52:58 +12:00
ispyisail 4283ddad10 linkElements: refuse a report link that would pop a modal dialog
Fixing LinkElementCommand's property comparison (previous commit)
means it now correctly detects a report-link colour/style mismatch --
which means it now correctly pops PotentialSelectorDialog for one,
same as the GUI. Under headless --run there is nobody to answer a
plain QDialog::exec(), so this hangs forever; confirmed directly with
a timeout before adding this guard.

qet.linkElements() now checks LinkElementCommand::
reportLinkNeedsPotentialChoice() before constructing the command and
declines with a clear reason pointing at checkContinuity() and
setConductorProperty(), the same choice addConductor() already makes
about ConductorCreator's own equivalent ambiguous-potential dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:52:48 +12:00
ispyisail 1046b97080 Fix report-link colour/style mismatch detection (bugtracker #974)
LinkElementCommand::redo() already had a check meant to catch exactly
this -- two report-linked conductors whose properties disagree -- and
ask the user which to keep via PotentialSelectorDialog. It never
worked: it built ONE combined list from three unrelated fields
(tension_protocol, wire_color, wire_section) and tested that whole
list for string equality, so a tension-protocol value could never
equal a wire-colour value even when every field individually matched
across every conductor. Worse, "wire_color"/"wire_section" are
ConductorProperties::m_wire_color/m_wire_section, a separate free-text
documentation pair that says nothing about how the wire is actually
drawn -- that's "color"/"style" -- so the one field #974 is actually
about was never compared at all.

Fixed by comparing each relevant field (text/num, function, tension
protocol, colour, line style) separately. Downloaded the reporter's
actual project, confirmed the mismatched wire reads color="#0000ff" on
one side of a "Folio suivant"/"Folio precedent" link and
color="#55aa00" on the other, with the link's other four conductors
matching correctly (ruling out a rendering artifact) -- see PR #980's
checkContinuity() extension, which now flags this class of mismatch on
sight.

Extracted the comparison into its own static
reportLinkNeedsPotentialChoice(), for the same reason
ConductorCreator::needsPotentialChoice() already exists as its own
method: a caller with nobody there to answer a modal dialog needs to
check first and decline, and the condition must not drift away from
the one redo() actually applies.

Fixing the comparison surfaced a real, previously-latent hang in this
session's own qet.linkElements(): PotentialSelectorDialog::exec() is a
plain QDialog::exec(), not routed through QET::QetMessageBox, so
headless --run has nobody to answer it. Measured directly -- hung
until killed with the property-comparison fix alone, clean refusal
after adding the guard. linkElements() now calls
reportLinkNeedsPotentialChoice() before constructing the command and
declines with a clear reason, the same choice addConductor() already
makes about ConductorCreator's own equivalent dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:52:38 +12:00
ispyisail a766f9a975 Sync misc/qet-mcp: report_link_mismatch check, NEXT_REPORT/PREVIOUS_REPORT fixtures
Brings misc/qet-mcp up to date with checkContinuity()'s new
report_link_mismatch finding: updated qet_continuity's description,
and two new tests reproducing #974 with the shipped 02going_arrow.elmt/
01coming_arrow.elmt pair (no custom fixtures needed -- this element
type already ships).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:37:43 +12:00
ispyisail 68503e2a7d checkContinuity: catch cross-folio report-link colour/style mismatches
New report_link_mismatch finding (severity "warning", not "error" the
way potential_mismatch is): a next_report/previous_report folio-jump
pair whose conductors disagree on colour, style, num, or any of the
other checked properties. Unlike potential_mismatch, this one is not
proof of external tampering -- LinkElementCommand::isLinkable() only
ever checks type and freedom (see its own doc comment), never conductor
properties, so nothing in QElectroTech copies one side's colour onto
the other when a report link is made or keeps them in sync afterwards.
This is a real, unenforced gap reachable through completely ordinary
use, not a defect a script or the GUI could introduce.

Reproduces qelectrotech/qelectrotech-source-mirror#974 exactly:
downloaded the reporter's actual project, traced the mismatched wire to
a "Folio suivant"/"Folio precedent" link pair, and confirmed via query
that the two sides read color="#0000ff" and color="#55aa00" while the
link's other four conductors (0V/Low/High/Ground) matched -- ruling out
a rendering artifact. Verified fresh with a synthetic reproduction
(tests in misc/qet-mcp) using the shipped 02going_arrow.elmt/
01coming_arrow.elmt pair, giving exactly one finding, not one per
folio-link conductor.

Also fixes a real gap in the existing potential_mismatch check while
here: checked_properties was missing "color" and "style" entirely,
checking only "conductor_color" (ConductorProperties::m_wire_color, a
separate free-text documentation field, typically empty) -- meaning
the same-folio version of this exact bug class would have gone
undetected too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:36:45 +12:00
ispyisail 3b05f5cf13 Sync misc/qet-mcp with the extended scripting API
Brings misc/qet-mcp up to date with the scripting API additions in
this branch: qet_edit gains ops for tables, PLC master IO tables and
PLC-slave linking, manual conductor segment routing, polygon and path
shapes, PDF page import, and project-wide search & replace; a new
qet_continuity tool exposes the electrical continuity/ERC-style checks.

Adds the test suite that did not exist here before (150 tests: unit
validation, JSON-RPC protocol, and Integration/PlcIntegration/
CorpusIntegration runs against a built binary) plus the two minimal
PLC fixture .elmt files it needs (no shipped element has masterType/
slaveType "plc" to test against).

Also removes a __pycache__/*.pyc that had been committed by mistake,
and ignores __pycache__/*.pyc going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:03:04 +12:00
ispyisail 2906a48aab Add electrical continuity checking to the scripting API
qet.checkContinuity(folioIndex) runs two structural checks against the
live Terminal/Conductor object graph -- Terminal::conductors() and
Conductor::relatedPotentialConductors(), the same primitive
setConductorProperty() already uses -- rather than a heuristic read of
the saved XML:

- unconnected_terminal (info): a terminal with no conductor at all.
  Deliberately low severity -- routine (a spare relay contact, an
  unused optional pin), not necessarily a mistake.
- potential_mismatch (error): two conductors electrically on the same
  potential (following bridged terminal strips and linked report
  elements, matching setConductorProperty()'s own scope) disagreeing
  on num/conductor_color/conductor_section/function/bus/cable.
  QElectroTech's own edits always keep every member of a potential
  identical, so any divergence found here came from hand-edited XML,
  a legacy file, or an external tool -- verified with a test that
  patches a saved file's XML directly to introduce exactly that.

Documented plainly what this does NOT check and why: pin electrical
direction/power conflicts and No/Nc/Common contact shorts, since
QElectroTech's terminal data model (Generic/Inner/Outer/No/Nc/Common --
contact role within one relay, not signal direction) does not carry
the information either would need. This is continuity/consistency
checking, not full ERC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail a542b911e9 Add project-wide search & replace to the scripting API
qet.searchAndReplace(kind, field, pattern, replacement, useRegex,
caseSensitive) finds and replaces a substring or regular expression
within one text field across every folio, as a single undo step --
kind is element_info, conductor or text. Unlike a script loop over
elementInfo()/setElementInfo() (or the conductor/text equivalents)
doing the same thing one item at a time, each pushing its own undo
entry, this wraps the whole run in one macro.

This is deliberately NOT a wrapper around QET's own "Search and
replace" panel (SearchAndReplaceWorker): that one is a batch
overwrite-with-sentinel template built for picking items from an
interactive tree, a poor fit for a script that can already say
precisely which items it means. This does what the name plainly says
instead -- an actual substring/regex replace within each item's
current value.

Found and fixed while writing the first conductor-kind test: a hub
topology (several conductors sharing one terminal, e.g. a star wiring)
made the conductor branch pick terminal1 unconditionally to address a
Conductor object through setConductorProperty() -- for a hub member
conductor, terminal1 is the shared, ambiguous hub terminal itself
(findConductor() correctly refuses to address a conductor through a
terminal carrying more than one), so every conductor touching that hub
silently failed to update, returning a changed count of 0 with no error
for a genuinely matching project. Fixed by preferring whichever of
terminal1/terminal2 carries exactly that one conductor.

Also caught during testing: an empty macro (a run that matches
nothing) still gets pushed onto the undo stack by QUndoStack::endMacro()
-- it is not silently discarded the way the earlier revision assumed --
leaving a confusing no-op "Rechercher et remplacer" undo entry. Fixed
by counting matches in a dry run first and never touching the undo
stack at all when that count is zero.

textContent() is a new small getter alongside the existing
setTextContent(), filling a gap this needed (reading an independent
text's current content) that is independently useful too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 9cee71bde2 Add PDF page import to the scripting API
qet.addPdfPage() renders one page of a PDF file to an image and
places it, through the same QPdfDocument::render() call, white-
background compositing (a transparent page would otherwise show
whatever is under it, unlike every other placed image) and
DiagramImageItem/AddGraphicsObjectCommand underneath as the "add PDF"
toolbar action's own file/page-selection dialogs.

Only reachable in a build with the QtPdf module (Qt >= 6.4) -- some
Qt6 distributions omit it entirely (see diagrameventaddpdf.h). The
method is still always declared and compiled, guarded internally
instead of with the class itself: a script asking whether qet.addPdfPage
exists must never get "not a function" for a reason it has no way to
discover. Refuses with a clear reason when the module is missing, the
page number is out of range, the file cannot be loaded, or the
resulting render is degenerate.

Verified against a real 2-page PDF (this session installed qt6-pdf-dev,
which was missing here) that the two rendered pages differ in content
and that dpi scales the rendered pixel size linearly, not just that the
call returns something.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 687837d7a3 Add polygon and path shapes to the scripting API
addShape()'s own "polygon" only ever produces the degenerate two-point
form -- it shares addShape()'s p1/p2 constructor and nothing else.
addPolygon()/setShapePolygon() take an arbitrary point list through
QetShapeItem's public setPolygon(), pushed via the existing "polygon"
Q_PROPERTY the same way a point-handle drag would.

addPath()/setShapePathNodes() add the Path shape type: a polygon's
points plus, per node, a kind (corner/smooth/symmetric) and optional
bezier in/out handles, the same model the pen tool and node-edit mode
build. PathNode holds std::optional<QPointF> members and isn't
Q_PROPERTY-friendly, so setShapePathNodes() reuses PromoteShapeCommand's
before/after XML snapshot mechanism instead -- the same fallback
QetShapeItem::associatedUndoCommand() already uses for the identical
reason on a PathAnchor/PathControlIn/PathControlOut handle drag.

setShapeClosed() opens or closes a polygon or path through the existing
"close" Q_PROPERTY. shapePolygon()/shapePathNodes() read a shape's
current geometry back in scene coordinates, refusing (empty) on the
wrong shape type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail ea1f65107e Add manual conductor routing to the scripting API
Conductor::moveSegment(index, dx, dy) is the same primitive
handlerMouseMoveEvent()/handlerMouseReleaseEvent() apply on a drag --
move both axes on the target segment (each of ConductorSegment's
moveX()/moveY() silently no-ops on the wrong axis or a static,
terminal-anchored segment), recompute the path, and push one
ChangeConductorCommand undo step via the existing saveProfile().

Caught while writing the first test for it: moveSegment() never set
modified_path, so Conductor::toXml() skipped writing <segment> children
and a manually rerouted conductor silently reverted to auto-routing on
the very next save -- the change took effect in the running scene but
never reached disk. Fixed by setting the flag, the same as every other
path-modifying call site already does.

qet.conductorSegments() lists a conductor's segments (endpoints in
scene coordinates, orientation, static/movable) so a script can find
the index it wants; qet.moveConductorSegment() applies the move and
refuses a static segment or an out-of-range index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 6380e2a46b Add PLC master IO table and PLC-slave group-index linking
addPlcIO/setPlcIO/removePlcIO edit a PLC master's IO table (type,
address, function text, comment) directly through setElementData(),
the same as MasterPropertiesWidget's own PLC IO editor -- and, like it,
these are not undoable: MasterPropertiesWidget::associatedUndo()
deliberately returns nullptr for PLC masters, since their linking is
managed through the IO table rather than the link-tree widget it would
otherwise build an unlink-all command from.

linkElements() gains an optional groupIndex so a PLC slave can be
linked onto one specific IO row instead of leaving the row
unspecified. LinkElementCommand only reads the group index it is given
when the command's own element is the Slave -- when built from the
Master side (a=master, b=slave, the usual call shape) it looks in a
per-slave map this call never populates, and setGroupIndex() is
silently a no-op. Fixed by building the command from whichever of the
two elements is actually the Slave, matching what PlcLinkWidget does.
elementLinkGroupIndex() reads the result back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 219273a8e5 Add table placement to the scripting API
QetGraphicsTableFactory::create() only reads settings already set on an
AddTableDialog and never depended on the dialog being shown, so make it
public alongside setTableName()/setAdjustTableToFolio()/
setAddNewTableToNewDiagram() on AddTableDialog -- this lets the scripting
API build and configure a dialog headlessly instead of exec()'ing one.

qet.addTable() requires a non-empty query: ElementQueryWidget and
SummaryQueryWidget both default to zero selected columns, so an empty
query silently produced a table with no rows rather than a sensible
default. qet.tables()/deleteTable() list and remove by a position-sorted
index. qet.setTablePosition() repositions one, since newTable() always
places a new table at a fixed (50, 50) and a folio with more than one
needs to move all but the first itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 8a2d31d835 Let a script group, bridge and sort a terminal strip's real terminals
stripRealTerminals(strip)              index, owning element, current
                                          physical position, neighbours
  groupTerminals(strip, indices)         merge onto one physical position
  bridgeTerminals(strip, indices)        wire together without merging
  sortTerminalStrip(strip)               canonical physical order

Each goes through the same command the terminal strip editor's own
group/bridge/sort buttons push (GroupTerminalsCommand,
BridgeTerminalsCommand, SortTerminalStripCommand), so a script's changes
undo like the editor's.

groupTerminals() replicates the editor's own receiver-selection heuristic
line for line rather than picking the first terminal named: the physical
position that already carries the most real terminals receives the
others, not necessarily the one at index 0. Verified with a case built to
distinguish the two: three terminals grouped first (one position, three
real terminals), then a fourth, previously-alone terminal grouped with
one of those three, named first in the call -- the alone terminal moved
onto the three-terminal position, ending at four, not the other way
around.

bridgeTerminals() refuses through TerminalStrip::isBridgeable() itself,
the same check the editor's bridge button applies, rather than
re-deriving what "the same level" means. Real terminals are addressed by
index into stripRealTerminals(), the strip's own order; grouping shifts
later physical-position indices down, so the header says to re-list
after a change that adds or removes one, the same rule already
documented for texts, shapes and images.

Verified end to end on four placed terminal elements: added to a strip,
grouped two, refused a group of one and an out-of-range index, bridged
the remaining two, sorted, undo restoring order without disturbing the
grouping (sort doesn't touch it, so it shouldn't), and a bad strip index
refused on all three operations. Qt 6.10.2, build clean, ctest 12/12,
coherence gate clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail a8f883601d Let a script list, embed and apply a folio's title block template
titleBlockTemplates()          embedded + common/company/custom, by name
  embedTitleBlockTemplate(name)  copy one into the project's own collection
  setFolioProperty(f,"template",name)  embed-if-needed, then apply
  folioProperty(f,"template")

Not the trivial addition to the existing title-block-field list it looked
like at first. Diagram::setTitleBlockTemplate() resolves a name only
against QETProject::embeddedTitleBlockTemplatesCollection() -- the exact
same copy-into-the-project step addElement() already goes through for
elements, and for the same reason: a project opened on another machine
must not depend on files only this one has. embedTitleBlockTemplate()
does that copy through get/setTemplateXmlDescription(), the same round
trip the template editor itself uses to save one -- not scripting-specific
code, and unlike defining an auto-numbering context, not undoable, for the
same reason that isn't: the application does both through direct
collection/project calls with no undo command of their own.

Two things found only by testing, not by reading:

- "default" is a real template name in the common collection, and setting
  a folio's template to it is legitimate -- but
  BorderTitleBlock::titleBlockTemplateName() normalises a template
  literally named "default" back to "", indistinguishable from no
  override, since that is genuinely what "no override" renders with. The
  first version compared the raw name and reported success as failure;
  fixed by comparing against that same normalised form, which folioProperty()
  now also documents.
- QElectroTech resolves the common template collection from a compiled-in
  path (here, an absolute /usr/share/qelectrotech/titleblocks, not
  relative to the binary), and --common-tbt-dir, the CLI override, is
  read by QETApp::parseArguments() -- which the --run headless path never
  reaches, confirmed by the CLI itself swallowing the flag as a stray
  positional argument. There is no QSettings fallback the way
  commonElementsDir() has. So testing this at all needed the path to
  genuinely exist; no environment trick from inside the process reaches
  it.

Verified: 10 common templates listed; DIN_A4 embedded and applied,
folioProperty reading it back; re-applying the same name a no-op success;
an unknown name refused; "default" applied and correctly read back as ""
per the note above; both folios exported to PNG and visually compared --
plain default rendering vs. DIN_A4's logo, revision table and field
layout, genuinely different, not just an API call returning true. The
choice survives a save and reload. Qt 6.10.2, ctest 12/12, coherence gate
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail cd27912605 Let a script read where an element is, and insert a folio at a position
elementGeometry(folio, uuid)   x, y, rotation, left, top, right, bottom
  insertFolio(position)

The API could set an element's position but never read it, so a script
could not lay one thing out relative to another, or check that a move had
landed; verification had to go through the saved file. elementGeometry
returns the origin (what setElementPosition sets), the rotation, and the
box the element occupies on the folio -- its drawn extent, which sits at
the element's hotspot from the origin and, once rotated, is the rotated
extent.

Measured on a coil whose hotspot is (17, 32): placed at (200, 300) the box
is 183..223 by 268..328, exactly that far from the origin; a move of
(+50, -20) shifts both together; a 90 degree turn swaps the box to 60 by
40 about an unchanged origin and 180 turns it back; and the origin agrees
with the saved file (x=250 y=280, orientation 2 for 180 degrees).

insertFolio puts a folio at a position (0 first, folioCount() last) through
QETProject::addNewDiagram(pos), undoable. The position is checked in the
binding: QETProject::addDiagram() hands it straight to QList::insert(),
which is undefined past the end, so -1 and anything above the count are
refused with a reason. Verified: first, middle and last insertions land in
the right order, and undo and redo of an insertion restore the order.
Folio reordering itself still needs the application's project view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail f087820757 Let a script rename the project and reshape a folio's frame
setProjectTitle(title)
  folioBorder(folio, prop)  setFolioBorder(folio, prop, value)

The frame is the grid of columns and rows around a folio: columns,
column-width, display-columns, rows, row-height, display-rows -- the six
fields the folio properties panel offers -- through ChangeBorderCommand,
so it undoes like a hand edit. The title block's header sizes, which the
panel does not offer, are left alone. Changing the project title is not
undoable, because the application sets it directly too.

Counts are 1 to 99 and sizes 1 to 1000. The panel's upper limits are the
same; its lower limit is 0, which is not offered: a grid with no columns
has no use here and 0 was not tested, so it is refused rather than
assumed safe. The extremes that are offered (99 x 99 cells, widths from 1
to 1000) were exported to PNG without a hang or crash. Fractions, out of
range values, an unknown property and a bad folio are refused with a
reason.

Verified: ten columns of 40 and four rows of 100, undo of the last change,
and the folio and the renamed project read back after a reload. My first
reload check read the wrong folio and briefly looked like the border was
not persisted; the file itself had the right values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 2772b690e8 Let a script duplicate elements, with the conductors between them
duplicateElements(fromFolio, [uuids], toFolio, x, y)
  selectedElements(folio)

What Ctrl+C and Ctrl+V do: the named elements are selected, serialised
with Diagram::toXml(false, true), the previous selection is put back, and
the copy is pasted with Diagram::fromXml() and a single
PasteDiagramCommand. A conductor is copied only if both its ends are among
the copied elements. As on a paste in the application, the copies come
without labels or their conductors' wire numbers (measured: empty on
both). One undo removes the elements and the conductor together.

Three properties found by measuring rather than assuming:

- Position is the top left of the pasted group's bounding rectangle, so an
  element's own origin ends up offset by its hotspot: +20, +30 for a coil,
  identical across two trials. (0, 0) is not a position; Diagram::fromXml
  treats the origin as "keep the source coordinates".
- The application's pasted list is in scene order, not the order the
  elements were named. Asking for the elements at x = 700, 100, 900 returned
  the copies of 100, 700, 900, so a caller pairing copies with sources by
  index was wired to the wrong ones with no error. A paste is a pure
  translation, so sorting sources and copies by position pairs them, and
  the result is returned in the order asked. Checked with a scrambled
  request over a zig-zag layout: every copy is the identical translation
  (-30, -20) from the source at its index. Two elements at one point cannot
  be told apart; if the counts disagree it says so and returns the
  unpaired list rather than guess.
- Copying works by selecting, so the previous selection is given back;
  selectedElements() exists to make that checkable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 279b001a16 Let a script apply element auto-numbering
useElementAutoNum(name)   select the current element numbering context
  numberElement(folio, el)  give one element its label from it

numberElement calls Element::setUpFormula(), the call the "add element"
tool makes right after placing an element, so a script gets the same
numbering: three coils numbered in turn are K1, K2, K3, and the counter
persists in the project (after a reload the next element is K3).

It is a separate call rather than a change to addElement(), which is
merged code: numbering what it places would change what an existing
script produces the moment its project happens to have a context selected.
A slave or a report is refused, since it takes its label from its master,
and so is a project with no context selected, instead of reporting a
success that did nothing.

setUpFormula() has a hazard for an element that is already placed. It
writes the label straight into the element's information and pushes only
the counter's advance onto the undo stack. Placing a new element hides
that, because undoing the placement removes the element; for an existing
one, a single undo rolled the counter back and left the label, so c3 stayed
"K3" while the counter went back to expecting K3 and the next numbering
would repeat a label it had forgotten. So the label it computed is taken,
the information put back, and the change pushed as a command inside the
same macro as the counter: one undo now reverts both, and renumbering c3
afterwards yields K3 again. Redo and the database agree.

Folio auto-numbering is deliberately not offered: in the application it
spawns whole new folios from a context, which is a different operation
from labelling. "Renumber existing conductors" has no equivalent to bind --
conductor numbering is applied when a conductor is created or moved, and
QElectroTech has no renumber-all action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 59cc758b7f Let a script control the text fields drawn on a symbol
elementTexts()  addElementText()  setElementTextProperty()
  elementTextProperty()  deleteElementText()

A symbol arrives with the fields its definition gives it -- coil
bobine_ka_a_remanence has A2, A1 and a label -- and until now a script
could fill the value the label shows (setElementLabel) but not control
the fields themselves: where each sits, its size, whether it is framed,
what it is bound to, or add and delete one.

A field's source is "text" (a fixed string), "info" (follows one of the
element's information keys, so it follows setElementInfo and
setElementLabel) or "composite" (a formula). Changes go through
QPropertyUndoCommand on the item's own properties, as the element-texts
editor does, and adding through AddElementTextCommand. An unknown source,
a key that is not an element information key, a non-positive size and a
bad index are refused with a reason.

Two things called text differ for an information-bound field. The "text"
property is the stored string, an unused placeholder (empty, or "Texte"
for a newly added field); "shows" is what is drawn. I first believed the
displayed text was stale in-session and wrote a helper to read it from the
element's information instead. That was wrong: comparing toPlainText()
against elementInfo() at seven points across relabel, rebinding, setting
and undo found no difference. It was the stored string that looked stale,
and the helper and the comment claiming the item "refreshes lazily" are
removed. The header now says which is which.

Fields are addressed by index in the element's own list, which follows the
definition and shifts on delete; undoing a deletion puts the field back at
the end. Consecutive info/label changes on one element merge into one undo
step (ChangeElementInformationCommand::mergeWith), so one undo can revert
several -- noted, not a bug.

Verified on a coil: the label field moved, enlarged to 14 pt and framed, a
new field bound to "comment" showing "24VDC coil", all through the
project, exported to PNG and looked at. The saved file carries the moved
position, size, frame and bound value, and a reload reads them back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 0c958d3b66 Drop "version" from the folio properties: it was a silent no-op
setFolioProperty(f, "version", "V9-USER") returned true and changed
nothing. TitleBlockProperties::version is the file-format stamp
QElectroTech writes on every save, so a value set through the API reads
back as "0.200.1-dev" and is what ends up in the file.

A property that reports success and does nothing is the failure this API
is careful to avoid everywhere else, so it is removed rather than
documented. It went in untested: the earlier verification set author and
plant and never tried the other six names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 528d33792d Say what order a terminal index actually follows
The comment on elementTerminals() said the order of Element::terminals()
"also comes from the definition", which reads as file order and is
wrong. Element::parseTerminal() re-sorts the list on every insertion, top
to bottom then left to right on each terminal's local position, so index 0
is the topmost terminal whatever order the .elmt lists them in.

bobine_ka_a_remanence.elmt writes A2 (y=20) before A1 (y=-20) and index 0
is A1. Measured by placing 400 shipped elements and reading the real order
back: a top-to-bottom, left-to-right prediction matched all 400, while
file order matched only the 100 where the two happen to coincide. Of the
837 shipped elements with distinct named terminals, 619 list them in a
different order than QElectroTech indexes them.

Comment only. Two terminals at one point tie and the sort is not stable;
the comment says so.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail fc41cf9f5d Let a script place an image
images()  addImage()  setImageScale()  setImageRotation()  deleteImage()

addImage reads a file the way the "add image" tool does after its file
dialog and pushes the same AddGraphicsObjectCommand. The pixels are
copied into the project, which DiagramImageItem::toXml writes inline, so
the saved file does not refer to the original path: verified by moving the
source file away and reloading, where the image and its scale came back.
The price is that the project grows by about the size of the image, so
files over 10 MB are refused; so are unreadable files, non-images and
missing paths, each with its own reason.

Scaling sets both axes as one undo step. Images are addressed by index in
a position-sorted listing like texts and shapes, by the on-screen
bounding box -- so because an image turns and scales about its centre,
scaling or rotating one can change where it sorts, and the header says to
re-list after either. Observed: a 2x scale moved a listed top-left from
(100,100) to (68,84), which is that, not a displacement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:31 +12:00
ispyisail 7f04bc1572 Let a script style a shape
shapeProperty()/setShapeProperty()   color, fill, width, line-style, rotation

Through QPropertyUndoCommand on the pen, brush and rotation properties
the shape's own style editor changes, so it undoes like a hand edit.
fill takes a colour or "none" for no fill; line-style is solid, dashed,
dotted or dashdot. A colour that does not parse, a non-positive width,
an unknown style or property and a bad index are refused with a reason.

Verified on a rectangle: all five set and read back, fill "none" reads
back as none, undo restores the previous fill, and the whole look
survives save and reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 27bbf51024 Let a script style a conductor
setConductorProperty()/conductorProperty() now also take the properties
that control how a conductor looks, under the names the .qet file uses:

  style        normal, dashed or dashdotted
  bicolor      true/false, with color2 as the second colour
  dash-size    positive integer
  condsize     positive number (line width)
  numsize      positive integer (text size)
  displaytext  true/false

Values are validated rather than stored: a boolean other than
true/false, a non-positive size, an unparseable colour or a line style the
file format cannot express is refused, because ConductorProperties would
write the latter back as a solid line and silently lose it. The change is
still applied to the whole potential.

Verified: all eight set, read back, written to the file in its own form
(style as "line-style: dashed;", displaytext as 0) and read back
again after a reload. Four invalid values decline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 65299e1178 Let a script define and select auto-numbering
autoNums(kind)                 list contexts and their formulas
  addAutoNum(kind, name, parts)  define one; parts are "type[:value[:increase]]"
  removeAutoNum(kind, name)
  useConductorAutoNum(folio, n)  new conductors on a folio take it

Kinds are conductor, element and folio. Part types are the ones the
auto-numbering dialog offers; anything NumerotationContext rejects, an
unknown kind, a non-numeric increase or a missing context is refused
with a reason rather than dropped.

Defining and removing a context is not undoable, because the
application does it through direct project calls; only the counter
advance is on the undo stack. useConductorAutoNum sets both the folio's
name and the project's current name, because the numbering code reads
the context by one and writes the advanced counter back under the other.

Verified: a "W" + unit context selected on a folio, two conductors
wired, W1 and W2 in the saved file, and the context survives reload.
Depends on the preceding ConductorCreator fix for the database to agree
with the drawing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 8e2a29deaf Update the database row once an auto-numbered conductor has its number
ConductorCreator inserts a conductor and only afterwards calls
refreshText(), which resolves the auto-numbering formula into
properties.text. The project database inserted its row while text was
still the raw formula, and refreshText() writes the resolved text
without emitting propertiesChange -- the signal the database listens
for -- so nothing corrects the row.

Measured: with a conductor auto-numbering "W%sequ_1" selected, two
wired conductors read W1 and W2 on the live objects and in the saved
file, but "W%sequ_1" and "W%sequ_1" in conductor.text and in
wiring_list_view.wire_number. A full updateDB() corrects it, so the
data was right and only the cache was stale. Anything that reads the
database between creating a conductor and the next rebuild -- the
wiring list, a BOM export, a custom query -- sees the formula, not the
number.

Update the row after refreshText(). The row change deliberately emits no
dataBaseUpdated(), as updateConductor() already documents, so this adds
no model re-queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail 5b739c3338 Let a script build a terminal strip
terminalStrips()        the project's strips, in its own order
  addTerminalStrip()      AddTerminalStripCommand, as the creation dialog does
  removeTerminalStrip()   RemoveTerminalStripCommand
  addTerminalToStrip()    AddTerminalToStripCommand

Only terminal-type elements can be added, the restriction the editor
enforces by construction; anything else, or a terminal already on a
strip, or a bad index, declines with a reason. Verified: two terminal
elements placed and added, listing shows 2 terminals, removal and undo,
and the strip with its terminals survives save and reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
ispyisail a1e4e153ae Let a script delete a conductor or a folio, and set title block fields
deleteConductor()    the conductor on a terminal, addressed as elsewhere
  removeFolio()        RemoveDiagramCommand, the GUI's delete-folio command
  setFolioProperty()   title, author, filename, plant, locmach, indexrev,
  folioProperty()      version, folio -- via ChangeTitleBlockCommand

deleteConductor removes only the named conductor; unlike a property
change it is not potential-wide, and DeleteQGraphicsItemCommand rebuilds
the rest of the potential so it stays connected, as when a user selects
one conductor and presses Delete. Verified: deleting one leaf of a
three-terminal potential leaves the other conductor, with its number.

removeFolio skips the GUI's confirmation box, which nobody could answer
headlessly. Undo and redo both work; later folio indexes shift down.
Undoing a removal prints a UNIQUE-constraint warning from
projectDataBase::addDiagram. That is inside RemoveDiagramCommand::undo(),
which the GUI runs too, so it predates this change.

The date and template are not offered as folio properties: the date has a
use-current-date mode a plain string cannot express honestly.

Verified headlessly, including save/reload of the title block fields.
Qt 6.10.2, build clean, ctest matches master.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:56:30 +12:00
Laurent Trinques 56f60be60a CI: try to fix 404 error on downlad page 2026-09-22 10:16:49 +02:00
Laurent Trinques 852f581206 Merge pull request #971 from arummler/fix-translation-syntax-02
Fix translation syntax follow-up
2026-09-22 09:57:30 +02:00
Laurent Trinques 3b626a7d1a CI: try to fix 404 error on downlad page 2026-09-22 09:50:14 +02:00
Andre Rummler 069a75d44b Use pugixml target in test removing the old directly including approach. Fix a static variable which is now missing as the correspondign source file is not used by the particular test. 2026-09-22 09:08:03 +02:00
Andre Rummler e1f887a036 Standarize pugixml header path to recommended variant with target INTERFACE. 2026-09-22 09:07:27 +02:00
Laurent Trinques c5edd0a54b CI: try to fix 404 error on downlad page
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m11s
2026-09-22 08:19:23 +02:00
Laurent Trinques 3a13287ba9 CI: delete auto-doxygen.yml 2026-09-22 08:05:53 +02:00
Andre Rummler 24776bfcf6 Avoid setting globally QLocale which was introduced in a recent MR as it will change number formats, etc. which is not necessarily what a user switching language wants. Beside that
small fix of a trasnlation comment.
2026-09-22 08:03:07 +02:00
Andre Rummler 3202c145e8 Fix French origin strings (language improvement). 2026-09-22 08:03:07 +02:00
Laurent Trinques 4888ae87f3 Merge pull request #978 from Kellermorph/feature/refresh-all
fix: update composite text %{label} when folio changes
2026-09-22 07:53:36 +02:00
Laurent Trinques 59344eb565 Merge pull request #977 from ispyisail/fix/591-text-resize-handles
Fix #591's resize handles: unreachable via plain click, wrong position
2026-09-22 07:51:32 +02:00
Kellermorph 5d033bf1b2 Add custom application color picker in global settings
Add a KColorButton next to the 'Utiliser les couleurs du système'
checkbox in the Apparence settings tab:

- When the checkbox is active (default), system colors are used
  and the color button is disabled
- When the checkbox is inactive, the color button becomes active
  and lets the user pick any color for the entire application
- useCustomPalette() builds a full QPalette from the chosen color
  with proper light/dark text contrast, button shading, and icon
  theme switching
- The chosen color is persisted in QSettings as
  'customapplicationcolor' and restored on next startup

Files changed:
- sources/ui/configpage/generalconfigurationpage.ui: HBoxLayout
  with checkbox + KColorButton, customwidget declaration
- sources/ui/configpage/generalconfigurationpage.h: new slot
- sources/ui/configpage/generalconfigurationpage.cpp: load/save
  custom color, enable/disable logic, toggled slot
- sources/qetapp.h: useCustomPalette() declaration
- sources/qetapp.cpp: useCustomPalette() implementation,
  startup restore of custom color
2026-09-21 22:05:31 +02:00
Kellermorph 8985babfe7 fix: update composite text %{label} when folio changes
DynamicElementTextItem::updateLabel() resolved %{label} in composite
text using the stale value from elementInformations()["label"], which
is only set once at load time and never updated when the folio/page
number changes.

Use element->actualLabel() instead, which resolves the label formula
(including %F, %f, %id) against the current folio at call time.
2026-09-21 21:45:03 +02:00
ispyisail 29d16c3337 Fix #591's resize handles: reachable only via Shift/right-click, wrong position
Two bugs reported by @arummler on #591 after merge:

  "It works but to select the text field one has to right click on
  it...I think there are competing handlers or something."
  "the drag elements should be on the border of the box. In the
  moment they appear directly left and right from the text."

Both reproduced headlessly (scripts/qet-gui-dialog.sh) against a fresh
build of current master and root-caused before touching anything.

Selection: DynamicElementTextItem::mousePressEvent() forwards a plain
click (no Shift) straight to parentElement()->mousePressEvent(), by
design and pre-existing -- it's what lets dragging a symbol by its own
label move the whole symbol rather than just the label. That's correct
and untouched here. But it means a plain click leaves the *parent*
selected, not the text, and #591's handles were wired only to the
text's own ItemSelectedHasChanged -- so they were only reachable via
Shift+click or a right-click's context menu (which happens to select
the item under the cursor for its own context menu, unrelated to the
Shift path), neither of which anyone reaches for to resize a text.
Confirmed with screenshots at each step, including that Shift+click
already reached the existing (if misplaced) handles correctly.

Fix: DynamicElementTextItem::refreshResizeHandlesVisibility() shows the
handles when either the text itself or its parent element is selected,
and Element gets an itemChange() override (it had none) that calls it
on each of its own texts when the element's own selection changes. Both
sides driven from itemChange(), Qt's own hook for exactly this and the
same one already used for the text's own selection.

First attempt drove this from paint() instead, since the PR's own
updateResizeHandlesPos() already runs there. That crashed reproducibly
(SIGABRT) on deselecting a text: paint() runs while QGraphicsScene
iterates its item list to draw it, and addResizeHandles()/
removeResizeHandles() mutate that list via QGraphicsScene::addItem()/
removeItem(), which cannot safely happen mid-iteration. Caught it with
the same headless repro before it went anywhere near a PR, moved the
logic to itemChange(), and re-ran the full sequence -- select, resize,
undo, deselect, twice through -- clean.

Position: updateResizeHandlesPos() placed the handles on frameRect(),
which is a box sized to the text's natural (idealWidth()) content and
then re-centred inside boundingRect() -- it does not grow with
textWidth(). Once a text has been widened, frameRect() stays tight
around the glyphs while boundingRect() -- the box QGraphicsView actually
outlines as the selection, and the box a user drags relative to -- grows
around it, leaving the handles stranded well inside the visible
selection border. Fix: position them on boundingRect() instead, which
does track textWidth(); confirmed by widening a text and checking the
handle lands exactly on the new edge rather than partway across it.

Verified headlessly end to end on the original report's own element
("motor off" on grafcet.qet, folio 1): a single plain left-click (no
Shift, no right-click) now shows both handles at the true box border;
dragging resizes correctly and the handle tracks the growing edge;
Ctrl+Z restores the -1 auto-width sentinel and the handles stay at the
reverted position; clicking away removes them; repeated twice with no
crash. Qt 6.10.2, ctest 12/12, no new warnings in either changed file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 07:30:39 +12:00
Laurent Trinques 61f5e5e502 Merge pull request #972 from arummler/fix-pugixml-linking
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m42s
fix PUGIXML linking
2026-09-21 19:36:28 +02:00
Laurent Trinques 77bc9ed8e4 Merge pull request #976 from arummler/untrack-qch
Stop tracking *.qch via LFS.
2026-09-21 19:35:48 +02:00
Andre Rummler 9b26d5dc6a Stop tracking *.qch via LFS. 2026-09-21 17:57:59 +02:00
Andre Rummler b76d8ce8a1 Fix: remove PUGIXML files from compilation fileset. This is redundant using the target approach and leads sometimes to failures. 2026-09-21 13:29:40 +02:00
Laurent Trinques d7052e396b Merge pull request #591 from ispyisail/feature-dynamictext-drag-resize
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m32s
Add drag-to-resize for dynamic element text width (#577 phase 1)
2026-09-21 12:19:22 +02:00
Laurent Trinques e123c55754 Merge pull request #969 from ispyisail/feature/qet-mcp-server
Add misc/qet-mcp: a Model Context Protocol server over QET projects
2026-09-21 12:00:34 +02:00
Laurent Trinques 268aba60eb Merge pull request #970 from ispyisail/feature/scripting-draw-api
Scripting API: draw, cross-reference and query a project
2026-09-21 11:57:48 +02:00
Laurent Trinques 313f9533a8 Merge pull request #967 from Kellermorph/fix-conductor-style
fix: inherit conductor line style (pen style) when linking cross-references
2026-09-21 11:55:05 +02:00
Laurent Trinques 3d1cb671c1 Merge pull request #966 from Kellermorph/place-makro-fix
fix: correct macro placement position mismatch
2026-09-21 09:21:34 +02:00
Laurent Trinques c0fc093b4e Merge pull request #968 from Kellermorph/background-drawing-selection
Add diagram background color picker with adaptive border/titleblock
2026-09-21 09:20:31 +02:00
ispyisail 46c35d2297 Let a script query the project database
Every structural question this API could answer, it answered by walking
live objects. The project builds a SQLite database that already knows
most of them, and nothing outside the application could reach it.

  tables()      what is queryable, tables and views
  query(sql)    rows, one object per row
  queryError()  why the last one returned nothing

This is not a new door. QElectroTech already ships a "Requête SQL
personnalisée" box in the element-query dialog where a user types
arbitrary SQL, guarded by projectDataBase::isReadOnlySelect(); query()
goes through projectDataBase::newQuery(), which applies that same rule
and returns the same rejection message. A script gets what a user
already has, and neither can write: DELETE, UPDATE and a chained
"SELECT 1; DROP TABLE" are all refused before reaching SQLite.

An empty result and a failure are told apart. query() returns no rows
for both, so queryError() carries the reason -- a refusal, or SQLite's
own message for a bad column -- and is empty when the query simply
matched nothing. Conflating those is how a silent typo in a column name
becomes "there are no such elements".

No updateDB() before querying, and that is a measured decision rather
than an omission. A script that has just edited something is the
expected caller, so a stale cache was the obvious hazard; but
projectDataBase maintains itself incrementally through addElement(),
elementInfoChanged(), addConductor() and the rest, which the undo
commands behind every edit already call. Tested both ways on the cases
most likely to go stale -- an element added and labelled, a conductor
property changed -- each queried immediately afterwards through both the
table and the view. Identical counts with the rebuild and without it,
and updateDB() repopulates every table, so calling it per query would
have been real cost for no benefit. The comment says so, so it is not
added back on the assumption it must be needed.

The views are the surface to depend on: element_nomenclature_view,
project_summary_view and wiring_list_view exist to be queried. The
tables are how the cache is arranged today and a column may move --
which is why tables() lists both and the header says which is which.

Verified against examples/industrial.qet, the largest shipped project:
618 elements counted, the busiest wire numbers ranked (0VDC 93 times,
24V2 64), and duplicate element labels found by GROUP BY ... HAVING --
V6 seven times, V5 six -- which is a design-rule question no tool here
could previously ask. Qt 6.10.2, ctest matches master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:14:53 +12:00
ispyisail 2adc58c1c0 Let a script add the text and shapes a folio carries
The drawing furniture beside the circuit: a free-standing note, a line,
a rectangle, an ellipse, a polygon.

  texts()  addText()  setTextContent()  setTextColor()
           setTextRotation()  deleteText()
  shapes() addShape()  deleteShape()

Added with the same AddGraphicsObjectCommand the corresponding GUI tools
use, and changed through the plainText/color/rotation properties those
items already publish, so a script's note undoes like a hand-placed one.

These are addressed by index into a listing sorted by position, reading
order, because they have no better identity: unlike an element they carry
no uuid, and unlike a conductor they have no terminal to be named by.
Position is what they have and it persists, so the ordering survives a
save and reload -- verified by listing before and after, including a
rotated text whose bounding box moves. It does not survive adding or
deleting one: indexes after that point shift the way a list's do, which
is why texts() and shapes() exist rather than a caller keeping a handle.

The sort is on sceneBoundingRect(), not pos(). A QetShapeItem keeps its
geometry in its line/rect/polygon and leaves pos() at the origin, so
sorting on pos() put three shapes drawn in three different places all at
(0, 0) and made every shape index refer to whichever the set yielded
first -- which is what the first version of this did, and the test that
caught it was asking for three shapes and getting index 0 three times.

Path is deliberately not offered: it is built by successive clicks and
has no two-point form to give here.

Verified headlessly: three texts added bottom-up and listed in reading
order, edited, recoloured, rotated, one deleted; three shapes added,
listed with their real geometry, the middle one deleted and the right one
gone; unknown shape name, invalid colour and out-of-range index all
decline with a reason. Saved, reloaded, both listings identical.

Qt 6.10.2, build clean, ctest matches master, qet-lint clean on the
generated project, qet-coherence-check clean on the example corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:00:04 +12:00
ispyisail 82262c5980 Let a script number a conductor and link a cross-reference
Two gaps left over from the drawing verbs. A script could create a
conductor but not say what it was -- no number, colour, section or
formula -- and could not link a master to its slave, although
LinkElementCommand has been there all along and nothing bound it.

  conductors()            what is on this folio, and how to address it
  conductorProperty()
  setConductorProperty()  num, formula, function, bus, cable,
                          tension_protocol, conductor_color,
                          conductor_section, color, text_color
  elementLinkType()       simple / master / slave / next_report / ...
  linkedElements()
  linkElements()          two folio indices: a master and its slave are
                          normally on different folios
  unlinkElement()

The property names are the ones the .qet file uses for the same fields,
so what a script sets is what a reader of the file sees rather than a
third spelling invented here.

A property is applied to every conductor of the same electrical
potential, not to the one conductor named. That is the rule the
application already follows -- SearchAndReplaceWorker pushes one
QPropertyUndoCommand per conductor of relatedPotentialConductors()
inside a macro -- because a wire number describes a potential, not one
drawn segment; setting it on one and leaving the rest of the potential
disagreeing would produce a file no GUI action could have produced.

Linking asks LinkElementCommand::isLinkable() rather than re-deriving
its rules, so a script cannot make a link the GUI would refuse: master
to master, a PLC master to a non-PLC slave, a next-report to another
next-report, or anything to an already-taken target.

A conductor is addressed as "the conductor on terminal i of element U".
It has no identity of its own to use instead: conductors carry no
persisted uuid, and the terminal1/terminal2 ids in the file are
folio-scoped integers QElectroTech renumbers on every save. Since the
change is potential-wide, any terminal of the potential names it equally
well, so in practice a potential is addressed from one of its leaves; a
terminal carrying several conductors names none of them and is refused
rather than guessed at.

Verified headlessly. Conductor: num, section and colour set from one end
of a potential and read back from the other, saved and reloaded, present
in the XML. Propagation shown to discriminate, which took two tries --
the first attempt wired A.0-B.0 and B.1-C.0 and saw no propagation,
correctly, because a coil's two terminals are opposite ends of the coil
and not one potential. Wiring a real hub at A.0 instead, a number set
via the B leaf appears on the C conductor too, in memory and in the
saved file. Cross-reference: a master on one folio linked to a slave on
another, linkedElements() agreeing from both ends, surviving save and
reload with link_uuid written on both folios; master-to-master,
self-link, unlink and relink all behave. Unknown property, invalid
colour, bare terminal and ambiguous terminal all decline with a reason.

Qt 6.10.2, build clean, ctest matches master, qet-coherence-check clean
on the example corpus, qet-lint clean on the generated projects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:48:40 +12:00
ispyisail 6dcb6a2a8f Key a conductor by something that survives a save
qet_diff keyed each conductor on its raw terminal1/terminal2 pair, with
a comment claiming that pair was "stable within a folio". It is stable
within a folio; it is not stable across a save. QElectroTech reassigns
those folio-scoped integer ids on every write, in whatever order it
serialises the elements, so one untouched conductor of ArduinoLCD.qet
goes from terminal1="1" terminal2="16" to terminal1="34" terminal2="15".

Diffing a project against a re-saved copy of itself therefore reported
29 of its 47 conductors as removed and 29 as added, with nothing
changed. That is the main thing this tool is for, so the conductor half
of the answer was noise in exactly the case it was wanted.

The format has two addressing schemes and a file can hold both at once.
Older conductors use the integer ids with no element1/element2; current
ones use terminal uuids from the .elmt definition plus element1/element2
naming the placed instances. A terminal uuid alone is not an identity --
it belongs to the definition, so two coils of one type share it and a
conductor between them keys as a self-loop -- so an end is identified by
the (instance, terminal) pair, taken from the conductor where it carries
one and resolved through the folio's elements where it does not.

Where an element predates persisted uuids there is nothing stable to key
on. Keying those on terminal geometry alone collapsed nine distinct
conductors of schema_indus.qet onto a single key, which is worse than
the instability it was meant to fix, so such ends stay unresolved, keep
a "#"-marked key, and the diff reports unstable_keys and says in words
that added/removed may not mean what they look like.

Measured over the 24 shipped example projects, 3190 conductors: 0
colliding keys, against 8 for the geometry-only key. On a re-saved but
otherwise untouched project: 0 added, 0 removed, against 29 and 29
before this change. A project with two conductors genuinely added still
reports exactly two added and none removed, so the check still
discriminates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:38:46 +12:00
ispyisail c740cdf1ac Let a script wire, label and rotate, not only place
The scripting API (bugtracker #162) could place an element and move it,
and could count conductors but not make one. So a script could put a
coil and a motor on a folio and had no way to connect them, which is
most of what drawing is. This adds the missing verbs:

  addConductor()      wire terminal i of one element to terminal j of another
  rotateElement()
  setElementInfo()    any information key
  setElementLabel()   the label key, by name, since it is the one people want
  addFolio()
  setFolioTitle()
  elementUuids()      what is on this folio
  elementName()
  elementTerminals()  which terminal index is which, before wiring it

Each goes through the command the GUI already uses, so a script's edits
undo like manual ones and reach the project database the same way:
ConductorCreator (the drag-a-rectangle-over-terminals path, which is
what makes a new conductor inherit an existing potential's properties
and join auto-numbering), ChangeElementInformationCommand,
QETProject::addNewDiagram(), ChangeTitleBlockCommand. rotateElement()
pushes the same QPropertyUndoCommand on "rotation" that
RotateSelectionCommand pushes for an Element, rather than
RotateSelectionCommand itself, which works on the diagram's selection
and would mean rewriting the user's selection to rotate one element.

Terminals are addressed by index, not uuid. Terminal::uuid() is a
property of the catalog .elmt definition: empty for most of the
installed base, and where present, identical across every instance of
that element -- two coils of the same type placed side by side have
byte-identical terminal uuids, so a uuid cannot say which coil's A1 is
meant. elementTerminals() exists so a script can see the indexing
instead of guessing it.

The one real hazard is that ConductorCreator asks the user which
potential to inherit from when the two terminals sit on two different
existing ones, and it asks with a plain modal QDialog that
QET::QetMessageBox's non-interactive mode does not cover -- so under
headless --run there is nobody to answer and the call never returns.
Measured: with the check removed, that one call hangs until killed;
with it, it declines in 0.4 s. addConductor() therefore refuses that
case, the same way and for the same reason addElement() already refuses
the import-conflict dialog.

To make that check without duplicating the condition, existingPotential()
becomes static over an explicit terminal list and ConductorCreator gains
a public needsPotentialChoice() predicate. Behaviour of the GUI path is
unchanged; setUpPropertieToUse() passes m_terminals_list to the same code
it called before.

Verified headlessly against a copy of examples/ArduinoLCD.qet: new folio
titled, two coils placed, wired, labelled, an info key set and the
element rotated; saved, reloaded, and the conductor, label, title and
rotation (persisted as orientation="1") all read back. Re-saving the
result is byte-identical. qet-lint clean on the generated project;
qet-coherence-check clean on it and on the 24-project example corpus,
and shown to report 9 findings on a deliberately broken copy of the same
file, so the clean result discriminates. Qt 6.10.2, ctest identical to
master (the 61 failures are the vendored KDE ECM suite, present on both).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:57:29 +12:00
elevatormind 78caeaec48 Merge branch 'qelectrotech:master' into no-sqlite 2026-09-21 07:34:32 +02:00
ispyisail aab0a18506 Add misc/qet-mcp: a Model Context Protocol server over QET projects
A small stdio MCP server that lets an assistant read a project, ask what
an edit actually changed, and sweep a corpus. Standard library only --
Python 3.9+, no third-party dependencies, and the MCP SDK is not
required. Nothing in the build or the application refers to it; it sits
in misc/ beside make_icon_themes.py and is inert unless run.

It exists because verifying a change by screenshot is unreliable, and
that unreliability produced two wrong conclusions in a single review
session. A drag of a multi-element selection looked like it had left the
symbols behind and detached their labels; diffing the saved file showed
all four elements had moved by an identical (0,-80) and no label had
moved at all. An Apply button looked like it did nothing; it was
disabled because a required field was empty. Both times the pixels
misled and the file told the truth, so these tools read the file.

Seven tools: qet_project_info, qet_elements, qet_conductors, qet_diff,
qet_scan, qet_element_info and qet_export. Only qet_export launches
QElectroTech; everything else parses the .qet or .elmt directly, which
needs no display and cannot be confused by a dialog.

Two behaviours of QElectroTech are carried inside the tool rather than
left for the caller to rediscover. SingleApplication keys its socket on
applicationFilePath(), so a second launch of the same path forwards its
request to a running instance and returns that process's answer with no
error; qet_export therefore copies the binary to a unique temporary
path, gives it a private HOME and runs it offscreen. A symlink would not
do, because applicationFilePath() resolves it back. And the CLI matches
its export flags by exact string (cli_export.cpp:828) with the project
and output as positional arguments (:862, :882), so --export-bom=out.csv
is not recognised as an export at all and the run starts the interface
and hangs headless; the tool uses the positional form.

Worth recording for anyone extending this: the project database would be
a better query surface than the XML, but it is not reachable from
outside the application. projectDataBase::newQuery() and
isReadOnlySelect() are C++-internal and the JavaScript scripting API
exposes no SQL binding. A --query CLI verb, or a scripting binding,
would let this expose the guarded read-only SELECT surface instead.

Verified against the shipped examples: qet_scan reports 3190 conductors
across the 24 example projects with no cable value, and qet_diff
reproduces the four-element move above from the two saved files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:33:17 +12:00
Kellermorph ebab58e4b3 Add diagram background color picker with adaptive border/titleblock
Replace the white/grey toggle (m_grey_background) with a full color
picker widget (DiagramBgColorToolButton) in the Affichage toolbar,
matching the existing ConductorColorToolButton UX:

- Preset colors: White, Off-white, Light grey, Grey, Dark grey, Black
- Recently used colors section
- "Autre couleur..." opens QColorDialog for any custom color
- "Couleur système" restores the default dark-mode inverted background

New features:
- Diagram::m_custom_background_color flag: when the user picks a
  custom color, PaletteGraphicsView skips lightness inversion so the
  chosen color is displayed as-is
- Border and titleblock text/lines automatically switch between black
  and white based on Diagram::background_color.lightness(), so a dark
  background always shows a visible light border and titleblock content
- "Couleur système" restores Qt::white + re-enables inversion

Files changed:
- New: sources/ui/diagrambgcolorbutton.h/.cpp
- sources/diagram.h/.cpp: added static m_custom_background_color flag
- sources/palettegraphicsview.cpp: skip inversion when custom bg active
- sources/bordertitleblock.cpp: adaptive border pen color
- sources/titleblocktemplate.cpp: adaptive ink color for cell borders/text
- sources/qetdiagrameditor.h/.cpp: replace toggle with new widget
- cmake/qet_compilation_vars.cmake: register new source files
2026-09-20 22:58:08 +02:00
Kellermorph 45ca53c7a8 fix: inherit conductor line style (pen style) when linking cross-references
ApplyForEqualAttributes() was missing the 'style' attribute, causing
dashed/dash-dotted line styles to be lost when potentials are merged
via folio reports. Only color and other properties were copied.

Add style copy in single-element case and equality check in
multi-element case, matching the existing pattern for other attributes.
2026-09-20 21:56:43 +02:00
Kellermorph 2efce1752d fix: correct macro placement position mismatch
Fix macro elements jumping to upper-left corner instead of being placed
at the correct drop position.

Root cause: The preview offset used itemsBoundingRect() (all items
including children), while Diagram::fromXml() computed its translation
offset from top-level items only. This mismatch caused fromXml to
translate elements to the wrong position.

Changes:
- Compute top-level-only bounding rect in dummy diagram constructor
  to get the correct m_items_top_left reference point
- Pass final_pos + m_items_top_left to fromXml() so the internal
  translation yields the intended final position
- Add braces around single-statement for-loop in fromXml
- Remove empty else block leftovers from debug cleanup
2026-09-20 21:24:38 +02:00
Magnus Hellströmer 27dea3ffab refactor: use VACUUM INTO for database export
Remove the native SQLite backup API and direct SQLite library
dependency.
2026-09-20 20:49:02 +02:00
Laurent Trinques c256e2dd1a Merge pull request #958 from bhangart/fix/pin-fetchcontent-dependencies
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m37s
Fix/pin fetchcontent dependencies
2026-09-20 20:13:37 +02:00
Laurent Trinques 8e3a7a6ab6 Merge pull request #927 from ispyisail/fix/923-snap-element-text
Snap a device's text to the grid when it is dragged (#923)
2026-09-20 20:10:13 +02:00
Laurent Trinques 740271c993 Merge pull request #938 from arummler/fix-translation-syntax
Fix various singular/plural cosntructions
2026-09-20 20:08:53 +02:00
Laurent Trinques 1bec2700bd Merge pull request #924 from Kellermorph/auto-numbering-new-project
Add global auto-numbering rules to QElectroTech settings
2026-09-20 20:07:16 +02:00
Laurent Trinques 268e9c7bd9 Merge pull request #959 from jp2images/fix-dark-canvas-item-updates
Keep the dark canvas on QGraphicsView's own update path
2026-09-20 20:02:50 +02:00
Laurent Trinques 264a0f677f Merge pull request #964 from jp2images/remove-stray-sbom-files
Remove two files that slipped into #944
2026-09-20 20:02:27 +02:00
Laurent Trinques 9ccfb5e116 Merge pull request #963 from jp2images/fix-hover-tint-light-face
Darken the hover ink on a light button face
2026-09-20 19:45:28 +02:00
Jeff Patterson 04463f0954 Darken the hover ink on a light button face
QETStyle::hoverColor() lightened the highlight color until it read at
3.5:1 against the Light role. On a dark face that is the way to go; on a
light face lightening only fades the ink, so with a pale platform accent
that QET keeps (macOS's green selection color, black selection text) the
loop ran to white and every hovered line-art icon vanished. The ink now
moves away from the face, darker on a light face, lighter on a dark one,
and falls back to the button text color if twenty steps are not enough.

The hover test gets a row with that accent on each palette, and a new
test sweeps accents across hues and lightness on both palettes and
requires the hover ink to read at 3:1 on the face.

Fixes #962
2026-09-20 12:45:07 -05:00
Laurent Trinques 3245919c19 Merge pull request #961 from jp2images/fix-small-page-icons
Give every configuration page an icon at page size
2026-09-20 19:44:00 +02:00
Jeff Patterson 55ad2eccbf Remove two files that slipped into #944
sources/qetsbom.cpp and sources/qetsbom.h were untracked local files
that a directory-wide add swept into the rebuilt #944 commit. Nothing
references them; the build does not compile them.
2026-09-20 12:43:57 -05:00
Jeff Patterson 8b2548d601 Keep the dark canvas on QGraphicsView's own update path
Laurent found that moving an element on a #954 build left its terminals'
help lines behind at every step, on both palettes. The view listened to
QGraphicsScene::changed() so that render() would keep the scene's updates
flowing, and any receiver on that signal puts the scene on its Qt 4.4
compatibility path, which erases a moved item's own old rect only:
children bigger than their parent stay on screen. Master hides that with
FullViewportUpdate, which repaints the whole viewport on every change.

Paint the inverted folio through QGraphicsView::paintEvent() instead, with
IndirectPainting set for that call and the draw hooks painting into a
viewport-sized image, and hand the scene the viewport when the items are
drawn so it records where each item was painted, including a child whose
geometry is set while its parent paints. The listener and the
full-viewport update go. Two tests move a parent with a sheet-wide child,
read the backing store, and require the repaint to be a partial one.
2026-09-20 12:43:50 -05:00
Laurent Trinques c3aeb0bf83 Merge pull request #944 from jp2images/fix-stylesheet-palette-switch
Refresh style-sheet widgets after a live palette switch
2026-09-20 19:36:26 +02:00
Jeff Patterson 16d89f64fd Refresh style-sheet widgets after a live palette switch
Switching the system between light and dark while QET runs changed
the palette of every plain widget but left widgets that carry a style
sheet in the colors they were created with: the folio tab bar stayed
light in dark mode, and after a dark-to-light switch its Add folio and
chevron buttons hovered as a near-black box with the icon lost inside
it. QApplication::setPalette() does not reach a widget with a style
sheet; QStyleSheetStyle resolved its palette once, when the sheet was
applied, and keeps it. Seventeen call sites set a sheet on a widget and
five .ui files carry one, so any of them could show the stale palette.

QET::Palette::refreshStyleSheets() re-applies each such widget's own
sheet, which makes QStyleSheetStyle resolve it against the palette now
in force. QETApp::useSystemPalette() calls it after installing the
palette, so both the OS color scheme change and the "use system
colors" setting are covered.

tests/qttest/tst_qetpalette: a tab widget with the folio tab bar's
sheet is still drawn in the old colors after setPalette(), which is the
defect, and follows the palette after refreshStyleSheets(), in both
directions.

Fixes #943.
2026-09-20 12:20:01 -05:00
Jeff Patterson ac78c51b70 Give every configuration page an icon at page size
The settings and project dialogs list their pages with 64 or 128 pixel
icons, and two pages had theirs at 22 pixels only: the terminal-strip
page and the shortcuts page, which borrowed configure-toolbars. Both get
a 128 pixel icon drawn in the style of the other page icons, the
shortcuts page under its own name, configure-shortcuts. The SVG sources
sit beside the PNGs.

On a dark palette the Printing and Export pages were small too: their
128 pixel icons exist in the light theme only, and Qt inherits by name,
not by size, so the dark theme's small copies were scaled up instead.
make_icon_themes.py now aliases the light files of the sizes a dark name
lacks, when they read on the dark window at 3:1.

A test asks the theme for every page icon at 128 pixels, on both
palettes.

Fixes #960
2026-09-20 10:44:09 -05:00
Beat Hangartner 2e27f77b28 Added more verbose description of the difference between lightweight and annotated tags as comment. 2026-09-20 14:21:36 +02:00
Laurent Trinques 83fb525e27 Merge pull request #939 from elevatormind/backtrace-windows-disable
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m39s
Fix issue #937
2026-09-20 14:03:43 +02:00
Laurent Trinques d1808c7a89 Fix Draw the folio with inverted lightness on a dark palette- #954 2026-09-20 13:07:57 +02:00
Laurent Trinques 4e4f029d2d Merge pull request #957 from qelectrotech/revert-955-revert-954-feature-dark-canvas
Revert "Revert "Draw the folio with inverted lightness on a dark palette""
2026-09-20 13:05:38 +02:00
Laurent Trinques 242f134e0f Revert "Revert "Draw the folio with inverted lightness on a dark palette"" 2026-09-20 13:05:13 +02:00
Magnus Hellströmer 2dc88df29c feat(build): Enable backtrace detection on Windows 2026-09-20 12:48:10 +02:00
Magnus Hellströmer fd8135264f fix(build): Remove stale Windows backtrace comments 2026-09-20 12:48:10 +02:00
Magnus Hellströmer 827cd2a91e fix(build): support backtrace in MSYS2 2026-09-20 12:48:10 +02:00
Magnus Hellströmer cdd25189b5 fix(build): guard backtrace detection on Windows 2026-09-20 12:48:04 +02:00
Laurent Trinques f1313f5895 Merge pull request #955 from qelectrotech/revert-954-feature-dark-canvas
Revert "Draw the folio with inverted lightness on a dark palette"
2026-09-20 07:20:03 +02:00
Beat Hangartner 3f397f5f78 Explain why fetching by git tag is a supply chain risk
The pinning comment stated that a tag is mutable but not what an attacker
does with that, so the trade-off was hard to judge for anyone reviewing or
later undoing the pins. Spell out the mechanism: a tag is a name pointing
at a commit, anyone with push access upstream can force-push it elsewhere,
and FetchContent resolves it at build time, so a stolen maintainer account
or CI token makes every fresh build compile the attacker's code while
nothing changes here and the tag name still reads correctly. A commit hash
is derived from the content and cannot be moved that way.

Name the two cases where this was actually exploited: tj-actions/changed-
files in March 2025 (CVE-2025-30066), where tags v1 through v45.0.7 were
retargeted to a commit leaking CI secrets into build logs across more than
23,000 repositories, and aquasecurity/trivy-action in March 2026
(CVE-2026-33634), where 76 of 77 version tags were force-pushed to a
credential stealer for about twelve hours. Both were GitHub Actions rather
than CMake dependencies, which the comment says, because the point is the
shared mechanism of resolving a tag at build time.

Also document how to upgrade a pin, including that git ls-remote reports
the tag object for an annotated tag and the commit on the "^{}" line.

The note lives in fetch_pugixml.cmake, which fetch_kdeaddons.cmake and
fetch_singleapplication.cmake already refer to. Comments only; no build
behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:36:20 +02:00
Beat Hangartner 7ec13cbc1a Pin fetched dependencies to commit hashes instead of git tags
CMake fetches pugixml, SingleApplication and the three KDE Frameworks
modules by git tag. A tag is a mutable pointer that its owner can move,
so two builds of the same QElectroTech commit can silently get different
third-party sources, and a compromised upstream account can change what
every builder downloads without anything changing in this repository.
Pinning each dependency to the commit its tag currently points at closes
that, while keeping the tag name in a trailing comment so the intended
version stays readable.

No versions change. Every pinned commit is the one its tag resolves to,
checked with git ls-remote and confirmed by fetching each one and
verifying that git describe reports exactly the tag. The three KDE
modules live in separate repositories and therefore need separate
commits, so the single KF_GIT_TAG variable becomes three per-module
variables; passing -DKF_GIT_TAG=<ref> still selects one ref for all
three, unpinned, exactly as before, and KF_GIT_TAG stays defined so the
build summary in define_definitions.cmake is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:05:39 +02:00
Kellermorph 7fa1ff35b8 Add global auto-numbering rules to QElectroTech settings
Add a 'Numérotation auto' tab to the global settings page (Settings >
Nouveau projet) where users can define default auto-numbering rules
for Conducteurs, Eléments, and Folios. These rules are automatically
transferred to every new project created.

Changes:
- Add NumerotationContext::saveToSettings()/loadFromSettings() static
  helpers for persisting named numerotation contexts via QSettings
- Add 'Numérotation auto' tab to NewDiagramPage with three sub-tabs
  using SelectAutonumW widgets (same UI as project properties)
- Add save/remove/persist slots for conductor, element, and folio
  contexts with immediate QSettings persistence on every change
- NewDiagramPage::applyConf() saves autonum settings when editing
  global defaults (no project)
- QETProject constructor loads global autonum settings from QSettings
  for new empty projects
2026-09-19 13:47:28 +02:00
Andre Rummler 7e3420b1a5 Avoid constructing sentences. 2026-09-19 10:39:54 +02:00
Andre Rummler 183035d5ac Replace enumerator list. 2026-09-19 09:15:28 +02:00
Andre Rummler eb4109bb11 Replace manual enumerator sentence with qt trasnlator automatism. 2026-09-19 09:08:25 +02:00
Andre Rummler 717bf57677 Improve plural marking from previous commit. 2026-09-19 09:08:00 +02:00
Andre Rummler 861e5de25e Use qt own enumeration translation function. Fix plural forms. 2026-09-19 08:31:34 +02:00
ispyisail bf7bff595e Merge branch 'master' into revive/591-dynamic-text-drag-resize
Bringing the drag-to-resize work up to date with current master (662
commits) before asking for review again. Both files auto-merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CaKympWT3owLotCpEN2CFj
2026-09-19 11:30:31 +12:00
ispyisail 95d0e523fe Snap a device text to the grid when it is dragged (#923)
Dragging an element's text -- its label, article number, any of its
information fields -- moved it in free one-unit steps while everything
else in the editor snapped to the grid. Reported by pki791 in #923 for
labels moved with Shift.

QET moves a text with the mouse along five paths. Four snap and let Ctrl
place freely:

  DiagramTextItem::mouseMoveEvent        an independent text
  ElementTextItemGroup::mouseMoveEvent   a group of element texts
  ElementTextsMover::continueMovement    every OTHER selected element text
  QetGraphicsItem::setPos                elements, images, shapes

DynamicElementTextItem::mouseMoveEvent, the text actually under the
cursor, ended "setPos(new_pos)" with no grid and no modifier check. It is
otherwise the same function as the group's, which is why this reads as an
omission rather than a decision: the line this adds is that function's,
character for character.

The inconsistency was visible in one gesture. With two element texts
selected and one of them dragged, ElementTextsMover skips the driver item
and snaps the rest, so the text under the cursor was the only one on the
folio that did not land on the grid.

Verified on a virtual display (Xvfb + openbox) against a two-lamp fixture,
grid 10, reading the saved positions rather than the screen:

  Shift+drag the label      before (32.95, -11.55) -> (7.95, 23.45) off-grid
                            after                  -> (10, 20)      on-grid
  the co-selected label     (10, -10) -> (50, 20) on-grid, before and after
  Shift to grab, then Ctrl  -> (7.95, 23.45) off-grid, free placement kept

The last line matters: moving an element text needs Shift at press, and
the modifier is read at move time, so Ctrl still places freely -- press
with Shift, hold Ctrl to drag. Holding both from the press is a different
gesture, reserved by DiagramView::isCtrlShifting() for the view's mode
switch, and does not move the text at all. Nothing that was possible
before is lost.

Worth knowing when reviewing: 470 of the 492 element texts in the 24
example projects (95.5 %) sit off the grid today, because element
definitions place their default text at fractional offsets. The first
drag of almost any existing label will pull it onto the grid, by at most
half a grid step.

ctest 12/12, Qt 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:06:45 +12:00
ispyisail 81449faffd Add drag-to-resize for dynamic element text width (#577 phase 1)
Adds two QetGraphicsHandlerItem grip handles at the left/right edges of a
selected DynamicElementTextItem's frameRect(), reusing the exact same
handle class, scene-event-filter wiring, and live-drag-then-undo-on-release
pattern QetShapeItem already uses for its own diagram-level resize handles
(sources/qetgraphicsitem/qetshapeitem.cpp).

- Handles are created/destroyed on ItemSelectedHasChanged, matching
  QetShapeItem's convention (and ElementPrimitiveDecorator's, for the
  element editor's own primitives).
- Position is recomputed in paint() rather than hooked to specific
  mutators, since textWidth/font/text/rotation can all move frameRect()
  and there's no single itemChange notification that covers all of them.
- The drag delta is resolved through mapFromScene() into the item's own
  local coordinates, so a rotated text box still resizes along its own
  baseline rather than along the scene's x-axis.
- setTextWidth() is called live during the drag for immediate visual
  feedback (matching how QetShapeItem's handlerMouseMoveEvent live-updates
  geometry); only on release is a QPropertyUndoCommand pushed -- the exact
  same command the properties-panel width spinbox already uses
  (sources/ui/dynamicelementtextmodel.cpp), so no new undo-command class
  or XML was needed.
- The original textWidth() value is preserved as-is (including -1, the
  "auto" sentinel) for the undo command's old_value, separately from the
  concrete baseline used for the live drag's delta math -- otherwise an
  undo would replace "auto width" with a synthesized fixed width instead
  of actually restoring the auto-sizing state.

Scoped to DynamicElementTextItem per the discussion's phase 1 (the
buildable, no-new-XML piece); IndependentTextItem and the element editor's
PartText/PartDynamicTextField have no serialized width property to resize
yet and are left as explicitly out-of-scope follow-ups.

Verified headlessly (Xvfb + xdotool + scrot): selecting an element's
label text shows the two handles, dragging one live-resizes the text
(confirmed via the properties panel's width field updating in real time),
and undo/redo correctly restores the exact original width including the
auto-width (-1) case.
2026-08-01 17:51:26 +12:00
110 changed files with 13888 additions and 883 deletions
-1
View File
@@ -1 +0,0 @@
*.qch filter=lfs diff=lfs merge=lfs -text
-67
View File
@@ -1,67 +0,0 @@
name: Auto-build doxygen docs
on:
push:
tags:
- '**'
jobs:
doxygen:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: 'recursive'
show-progress: ''
- name: Setup and run doxygen
run: sudo apt update && sudo apt install doxygen graphviz qhelpgenerator-qt5 -y
- name: Set up Git LFS
run: |
git lfs install
git lfs track "*.qch"
- name: Run doxygen
run: doxygen Doxyfile
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.MR_TOKEN }}
commit-message: update QCH file
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
title: Update QCH Help file
body: |
- Updating QT Help file following commit ${{ github.sha }}.
- Auto-generated by [create-pull-request][1]
[1]: https://github.com/peter-evans/create-pull-request
branch: update-qch
labels: |
qch
cicd
delete-branch: true
add-paths: doc/*.qch
- uses: actions/upload-pages-artifact@v3
with:
path: ${{ github.workspace }}/doc/html/
deploy:
# Add a dependency to the build job
needs: doxygen
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
# Deploy to the github-pages environment
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
# Specify runner + deployment step
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4 # or specific "vX.X.X" version tag for this action
+4 -2
View File
@@ -28,7 +28,7 @@ jobs:
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates build-essential cmake ninja-build git pkg-config \
qt6-base-dev qt6-base-private-dev qt6-tools-dev qt6-tools-dev-tools \
libqt6svg6-dev libqt6sql6-sqlite libsqlite3-dev libcups2-dev \
libqt6svg6-dev libqt6sql6-sqlite libcups2-dev \
libxkbcommon-x11-0 \
xvfb openbox xdotool x11-utils
# extra-cmake-modules and the KF6 libraries are installed rather than
@@ -53,7 +53,9 @@ jobs:
git config --global --add safe.directory "$GITHUB_WORKSPACE"
cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DQT_VERSION_MAJOR=6
-DQT_VERSION_MAJOR=6 \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DQET_EXPORT_PROJECT_DB=ON
- name: Build
run: cmake --build build --parallel $(nproc)
+1 -10
View File
@@ -49,7 +49,6 @@ jobs:
mingw-w64-ucrt-x86_64-qt6-tools
mingw-w64-ucrt-x86_64-qt6-translations
mingw-w64-ucrt-x86_64-qt6-pdf
mingw-w64-ucrt-x86_64-sqlite3
mingw-w64-ucrt-x86_64-pkg-config
mingw-w64-ucrt-x86_64-kwidgetsaddons
mingw-w64-ucrt-x86_64-kcoreaddons
@@ -122,8 +121,7 @@ jobs:
-DQET_EXPORT_PROJECT_DB=ON \
-DCMAKE_C_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
-DSQLite3_INCLUDE_DIR=/ucrt64/include \
-DSQLite3_LIBRARY=/ucrt64/lib/libsqlite3.dll.a \
..
ninja -j"$NPROC"
@@ -196,13 +194,6 @@ jobs:
cp /ucrt64/bin/libgcc_s_seh-1.dll "$BIN/"
cp /ucrt64/bin/libstdc++-6.dll "$BIN/"
cp /ucrt64/bin/libwinpthread-1.dll "$BIN/"
SQLITE=$(find /ucrt64/bin -name "libsqlite3*.dll" | head -1)
if [ -n "$SQLITE" ]; then
cp "$SQLITE" "$BIN/"
echo "SQLite3 copied: $(basename $SQLITE)"
else
echo "WARNING: libsqlite3 not found in /ucrt64/bin/"
fi
cp "$GITHUB_WORKSPACE/build-aux/windows/QET64.nsi" "$NSIS_ROOT/"
cp "$GITHUB_WORKSPACE/build-aux/windows/lang_extra.nsh" "$NSIS_ROOT/"
+53 -10
View File
@@ -59,17 +59,41 @@ jobs:
# ----------------------------------------------------------------
# 2. Download the portable artifact for this flavor
#
# Wrapped in nick-fields/retry: actions/download-artifact@v8 has
# shown repeated "Artifact download failed after 5 retries"
# failures on this cross-workflow download (via run-id) — not a
# real content/digest problem, just flaky Azure blob delivery.
# Two separate occurrences observed within days of each other
# (different artifact IDs/digests, same failure signature), each
# one enough to fail build-msi outright and block the rest of the
# pipeline. Retrying the whole download 3x is cheap insurance.
# Switched to `gh run download` here because nick-fields/retry
# can only retry a shell command, not re-invoke a `uses:` step.
# ----------------------------------------------------------------
- name: Download portable artifact
uses: actions/download-artifact@v8
uses: nick-fields/retry@v3
with:
name: ${{ matrix.portable_artifact }}
path: artifact\files
# workflow_run => use the triggering run's ID
# workflow_dispatch => use input run_id if provided, otherwise current run
run-id: ${{ github.event.workflow_run.id || github.event.inputs.run_id || github.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
shell: pwsh
command: |
if (Test-Path "artifact\files") { Remove-Item -Recurse -Force "artifact\files" }
New-Item -ItemType Directory -Force -Path "artifact\files" | Out-Null
$runId = "${{ github.event.workflow_run.id || github.event.inputs.run_id || github.run_id }}"
gh run download $runId `
--repo "${{ github.repository }}" `
--name "${{ matrix.portable_artifact }}" `
--dir "artifact\files"
if ($LASTEXITCODE -ne 0) {
Write-Error "gh run download failed (exit $LASTEXITCODE)"
exit $LASTEXITCODE
}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ----------------------------------------------------------------
# 3. Extract version
@@ -336,7 +360,13 @@ jobs:
if-no-files-found: error
- name: Delete old nightly .msi asset
if: always()
# Only run if a new MSI actually exists in dist/ — otherwise the old
# (still working) nightly .msi would be deleted without anything to
# replace it, leaving the nightly release with no MSI at all until
# the next successful build (see windows-msi-pipeline notes,
# run 35694391567: an upstream artifact-download failure meant
# dist\*.msi never existed for that run).
if: always() && hashFiles('dist/*.msi') != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
@@ -381,7 +411,20 @@ jobs:
deploy-pages:
needs: build-msi
runs-on: ubuntu-latest
if: always() && needs.build-msi.result == 'success'
# Regenerate the page whenever the MSI job actually ran (success OR
# packaging/signing failure) — not only on full success.
# generate-page.py queries the published assets on the "nightly"
# release itself (line 407: `gh release view nightly --json assets`),
# so it already handles a missing MSI natively (empty MSI_NAME -> no
# MSI button on the page). The page only needs the exe/zip already
# published by windows-build.yml, independent of the MSI's fate.
# Only "skipped"/"cancelled" are excluded: if build-msi never ran at
# all (e.g. Windows Build itself failed), there is nothing new to
# publish and regenerating the page would be pointless.
if: >
always() &&
needs.build-msi.result != 'skipped' &&
needs.build-msi.result != 'cancelled'
permissions:
contents: write
pages: write
+2
View File
@@ -1,5 +1,7 @@
*.snap
.flatpak-builder
__pycache__/
*.pyc
# Qt build output
*.user
# doxygen Doxyfile output
+3 -2
View File
@@ -113,7 +113,9 @@ if(Backtrace_FOUND)
include_directories(${Backtrace_INCLUDE_DIRS})
add_compile_definitions(QET_CRASH_BACKTRACE)
else()
message(STATUS "backtrace() not available: crash dumps will carry the log ring without a backtrace")
message(STATUS
"backtrace() not available: crash dumps will carry the log ring "
"without a backtrace")
endif()
find_package(SQLite3 REQUIRED)
@@ -316,7 +318,6 @@ target_include_directories(
${QET_DIR}/sources/NameList
${QET_DIR}/sources/NameList/ui
${QET_DIR}/sources/utils
${QET_DIR}/pugixml/src
${QET_DIR}/sources/dataBase
${QET_DIR}/sources/dataBase/ui
${QET_DIR}/sources/factory/ui
+23 -21
View File
@@ -27,18 +27,12 @@ git submodule update --init --recursive
| C++17 compiler | required | GCC or Clang on Unix-like platforms; MSVC or MinGW-w64 g++ on Windows — see [Choosing a compiler](#3-choosing-a-compiler-unix) / [Building on Windows](#6-building-on-windows-msvc--mingw) |
| Qt6 base + widgets | required | |
| Qt6 **GuiPrivate** headers | required | needed for clickable PDF hyperlinks; **hard build failure** at CMake generate time if missing, see below |
| SQLite3 | required | used by the nomenclature/summary database |
| Qt Linguist tools (`lrelease`) | required | compiles the tracked `.ts` files into `.qm` as part of every normal build |
| pugixml | handled automatically | fetched and built via CMake FetchContent if not already present on the system — see [pugixml](#8-pugixml) below |
| Qt Test module | required if building tests | `PACKAGE_TESTS` is `ON` by default; QtTest ships as part of the base Qt6 dev packages listed below on every platform, no extra package needed |
| KDE Frameworks (KF6) | optional | see [Building without KDE Frameworks](#9-building-without-kde-frameworks) |
| QtPdf module | optional | see [PDF page import](#7-pdf-page-import-qtpdf) |
A note on CMake versions: the project declares a minimum of 3.5 but is
routinely built with much newer releases; if your CMake is older than 4.3 it
simply won't have the newer `SQLite3::SQLite3` target name, which the build
script compensates for automatically. There is nothing you need to do either
way.
## 3. Building (out-of-source build)
@@ -106,7 +100,7 @@ the closest match.
sudo apt install \
build-essential cmake ninja-build git \
qt6-base-dev qt6-base-private-dev qt6-tools-dev qt6-tools-dev-tools \
libsqlite3-dev \
libkf6coreaddons-dev libkf6widgetsaddons-dev
```
@@ -138,7 +132,6 @@ sudo apt install libpugixml-dev
sudo dnf install \
cmake gcc-c++ git \
qt6-qtbase-devel qt6-qtbase-private-devel qt6-qttools-devel \
sqlite-devel \
kf6-kcoreaddons-devel kf6-kwidgetsaddons-devel
```
@@ -155,11 +148,10 @@ Optional, for a system pugixml: `sudo dnf install pugixml-devel`.
pkg install \
cmake git \
qt6-base qt6-tools \
sqlite3 \
kf6-kcoreaddons kf6-kwidgetsaddons
```
(from ports: `devel/qt6-base`, `devel/qt6-tools`, `databases/sqlite3`,
(from ports: `devel/qt6-base`, `devel/qt6-tools`,
`devel/kf6-kcoreaddons`, `x11-toolkits/kf6-kwidgetsaddons`.) Qt6's
`GuiPrivate` headers ship as part of `qt6-base` on FreeBSD, no separate
package is needed. `QtPdf` is not packaged on FreeBSD at the time of writing
@@ -175,7 +167,7 @@ Optional, for a system pugixml: `pkg install pugixml` (`devel/pugixml`).
Using [Homebrew](https://brew.sh):
```sh
brew install cmake qt sqlite ninja
brew install cmake qt ninja
```
Homebrew's `qt` formula is Qt6 and includes the private headers, so no
@@ -193,7 +185,7 @@ Optional, for a system pugixml: `brew install pugixml`.
See [Building on Windows](#6-building-on-windows-msvc--mingw) below — the
package sources differ enough from the Unix-like platforms above (no system
package manager, SQLite3 and Qt aren't provided the same way) that it gets
package manager and Qt aren't provided the same way) that it gets
its own section.
## 5. pugixml
@@ -215,7 +207,7 @@ on Debian/Ubuntu, `pugixml-devel` on Fedora).
Both toolchains QET's CMake build targets on Windows are covered here:
**MSVC** (Visual Studio 2019/2022) and **MinGW-w64** (gcc). Unlike the
Unix-like platforms above, there's no single system package manager, so
Qt, SQLite3 and (optionally) KDE Frameworks each need to be sourced
Qt and (optionally) KDE Frameworks each need to be sourced
separately per toolchain.
One piece of good news either way: unlike Debian/Fedora, the official Qt
@@ -236,11 +228,7 @@ normally use `-DBUILD_WITH_KF=OFF` (see the
1. Install Visual Studio with the "Desktop development with C++" workload,
and install Qt6 for MSVC (e.g. the `msvc2019_64` or `msvc2022_64` kit)
via the [Qt Online Installer](https://www.qt.io/download-qt-installer).
2. Get SQLite3 — the simplest route is [vcpkg](https://vcpkg.io):
```bat
vcpkg install sqlite3:x64-windows
```
3. Configure and build from an "x64 Native Tools Command Prompt for VS":
2. Configure and build from an "x64 Native Tools Command Prompt for VS":
```bat
mkdir build && cd build
cmake .. -G "Visual Studio 17 2022" -A x64 ^
@@ -271,15 +259,29 @@ mkdir build && cd build
cmake .. -G Ninja -DBUILD_WITH_KF=OFF -DCMAKE_BUILD_TYPE=Release
cmake --build .
```
If `libbacktrace` is installed in your MSYS2 environment, pass the following
cached CMake variables when configuring. They are only necessary in that
case, because `FindBacktrace` needs them to locate the library:
```sh
cmake .. -G Ninja -DBUILD_WITH_KF=OFF -DCMAKE_BUILD_TYPE=Release \
-DBacktrace_INCLUDE_DIR=/c/msys64/clang64/include \
-DBacktrace_LIBRARY=/c/msys64/clang64/lib/libbacktrace.a
```
`Backtrace_INCLUDE_DIR` must point to the directory containing `backtrace.h`,
and `Backtrace_LIBRARY` to the `libbacktrace.a` file. The paths above are the
usual locations for the MSYS2 `clang64` environment; adjust them if your
installation uses a different prefix.
(KF6 isn't packaged in MSYS2 either, hence `-DBUILD_WITH_KF=OFF` again.)
Using the Qt Online Installer's bundled MinGW kit instead: point
`CMAKE_PREFIX_PATH` at that kit (e.g. `C:\Qt\6.x.x\mingw_64`) and make sure
its bundled `g++.exe` comes first on `PATH`, or pass
`-DCMAKE_C_COMPILER`/`-DCMAKE_CXX_COMPILER` explicitly so CMake doesn't pick
up a different MinGW installation. SQLite3 still has to come from elsewhere
in this path — vcpkg with a `mingw`-flavoured triplet, or MSYS2's package as
above.
up a different MinGW installation.
## 7. Qt6 private headers (mandatory)
+1 -2
View File
@@ -71,7 +71,6 @@ parts:
source: .
stage-packages:
- git
- sqlite3
- xdg-user-dirs
- libqt6qml6
# libxcb-cursor0 workaround was needed against the Qt5/KF5 core22 content
@@ -82,7 +81,7 @@ parts:
- git
- cmake
- ninja-build
- libsqlite3-dev
- qt6-tools-dev
- qt6-base-private-dev
- qt6-declarative-dev
+28 -5
View File
@@ -23,8 +23,31 @@ if(BUILD_WITH_KF)
if(BUILD_KF)
if(NOT DEFINED KF_GIT_TAG)
# this is a more or less random version, taken as an conservative approach
# v6.10.0 is a more or less random version, taken as an conservative
# approach. Pinned to the commits v6.10.0 points at, not to the tags
# themselves; see the note in fetch_pugixml.cmake. Each module lives in its
# own repository, so the same v6.10.0 release is a different commit in
# each.
#
# KDE uses annotated tags, so "git ls-remote <repo> 'refs/tags/v6.10.0*'"
# prints two hashes per module: refs/tags/v6.10.0 is the tag object (the
# tagger, the date and the tag message) and refs/tags/v6.10.0^{} is the
# commit that object points at. The hashes below are the "^{}" ones, i.e.
# the commits. Lightweight tags, such as pugixml's v1.15 and
# SingleApplication's v3.2.0, have no tag object and print only the
# commit line.
set(KF_ECM_GIT_COMMIT 7dd28cc56c339c3f8fb356f7c53c0e8f61433d81) # v6.10.0
set(KF_KCOREADDONS_GIT_COMMIT c569f974dab24b4784ad186a3db4b76b2fa36612) # v6.10.0
set(KF_KWIDGETSADDONS_GIT_COMMIT 1abbed8a280d6626c59fb197f2c4667d2b1e7445) # v6.10.0
if(DEFINED KF_GIT_TAG)
# Explicit override: -DKF_GIT_TAG=<ref> selects one ref for all three
# modules, unpinned, exactly as it did before.
set(KF_ECM_GIT_COMMIT ${KF_GIT_TAG})
set(KF_KCOREADDONS_GIT_COMMIT ${KF_GIT_TAG})
set(KF_KWIDGETSADDONS_GIT_COMMIT ${KF_GIT_TAG})
else()
# Keep KF_GIT_TAG defined: define_definitions.cmake reports it.
set(KF_GIT_TAG v6.10.0)
endif()
# using a function in order to limit the scope of the variables
@@ -53,19 +76,19 @@ if(BUILD_WITH_KF)
FetchContent_Declare(
ecm
GIT_REPOSITORY https://invent.kde.org/frameworks/extra-cmake-modules.git
GIT_TAG ${KF_GIT_TAG})
GIT_TAG ${KF_ECM_GIT_COMMIT})
FetchContent_MakeAvailable(ecm)
FetchContent_Declare(
kcoreaddons
GIT_REPOSITORY https://invent.kde.org/frameworks/kcoreaddons.git
GIT_TAG ${KF_GIT_TAG})
GIT_TAG ${KF_KCOREADDONS_GIT_COMMIT})
FetchContent_MakeAvailable(kcoreaddons)
FetchContent_Declare(
kwidgetsaddons
GIT_REPOSITORY https://invent.kde.org/frameworks/kwidgetsaddons.git
GIT_TAG ${KF_GIT_TAG})
GIT_TAG ${KF_KWIDGETSADDONS_GIT_COMMIT})
FetchContent_MakeAvailable(kwidgetsaddons)
endfunction()
qet_make_kf_available()
+42 -1
View File
@@ -22,10 +22,51 @@ option(BUILD_PUGIXML "Build pugixml library, use system one otherwise" YES)
if(BUILD_PUGIXML)
# Pinned to the commit v1.15 points at, not to the tag itself.
#
# A git tag is only a named pointer to a commit, and anyone with push access
# to the upstream repository can move it (git push --force) to any other
# commit. FetchContent fetches whatever the tag points at when the build
# runs, so if a maintainer account or CI token is compromised, the attacker
# can retarget a well-known release tag to malicious code: every fresh build
# of QElectroTech then compiles it, while nothing changes in this repository
# and the tag name still looks correct. A commit hash cannot be moved, because
# it is derived from the content: different code always has a different hash.
#
# This attack has been used in the wild:
# - March 2025, tj-actions/changed-files (CVE-2025-30066): tags v1 through
# v45.0.7 were retargeted to a commit that dumped CI secrets into build
# logs, affecting more than 23,000 repositories.
# - March 2026, aquasecurity/trivy-action (CVE-2026-33634): 76 of 77
# version tags were force-pushed to a credential stealer and stayed
# malicious for about 12 hours.
# Both were GitHub Actions rather than CMake dependencies, but the mechanism
# is the same one FetchContent relies on here: resolving a git tag at build
# time.
#
# To upgrade, look up the commit the new tag points at with
# git ls-remote <repo> 'refs/tags/<tag>*', check that it is the release you
# expect, and update both the hash and the trailing tag comment.
#
# How many lines that prints depends on which of the two kinds of tag
# upstream created:
# - A lightweight tag is nothing but a ref pointing straight at the commit,
# so ls-remote prints a single line, "refs/tags/<tag>", and its hash is
# the commit to pin. pugixml tags this way, which is why the v1.15 hash
# below is what "git ls-remote ... refs/tags/v1.15" reports directly;
# SingleApplication (v3.2.0) does the same.
# - An annotated tag is a git object in its own right, carrying a tagger,
# a date, a message and optionally a GPG signature, and pointing at the
# commit. ls-remote then prints two lines: "refs/tags/<tag>" is the tag
# object and "refs/tags/<tag>^{}" is that object dereferenced, i.e. the
# commit. The KDE Frameworks modules tag this way, so for them it is the
# "^{}" hash that belongs in the pin; the other hash identifies the tag
# object itself, which is not the source revision and changes whenever
# upstream re-creates the tag, even over the very same commit.
FetchContent_Declare(
pugixml
GIT_REPOSITORY https://github.com/zeux/pugixml.git
GIT_TAG v1.15)
GIT_TAG ee86beb30e4973f5feffe3ce63bfa4fbadf72f38) # v1.15
set(PUGIXML_INSTALL OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(pugixml)
else()
+7 -1
View File
@@ -31,9 +31,15 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/SingleApplication/CMakeLists.txt")
set(FETCHCONTENT_SOURCE_DIR_SINGLEAPPLICATION "${CMAKE_SOURCE_DIR}/SingleApplication")
endif()
# Pinned to the commit v3.2.0 points at, not to the tag itself; see the note in
# fetch_pugixml.cmake. v3.2.0 is a lightweight tag, a ref pointing straight at
# the commit, so "git ls-remote <repo> refs/tags/v3.2.0" prints that commit and
# nothing else. An annotated tag, as KDE uses in fetch_kdeaddons.cmake, would
# print the tag object under refs/tags/v3.2.0 as well, with the commit on the
# refs/tags/v3.2.0^{} line.
FetchContent_Declare(
SingleApplication
GIT_REPOSITORY https://github.com/itay-grudev/SingleApplication.git
GIT_TAG v3.2.0)
GIT_TAG aede311d28d20179216c5419b581087be2a8409f) # v3.2.0
set(QT_DEFAULT_MAJOR_VERSION 6)
FetchContent_MakeAvailable(SingleApplication)
@@ -62,7 +62,9 @@ if(WIN32)
# puts the .qm files (see build-aux/windows/QElectroTech.wxs and the
# windows-build workflow), and what the shortcuts pass as --lang-dir.
set(QET_LANG_PATH "lang/")
set(QET_EXAMPLES_PATH "examples/")
set(QET_LICENSE_PATH "./")
set(QET_ICONS_PATH "icons/hicolor/")
# Liste des ressources Windows
#RC_FILE = qelectrotech.rc
endif()
+8 -4
View File
@@ -248,6 +248,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/qet.h
${QET_DIR}/sources/qeticons.cpp
${QET_DIR}/sources/qeticons.h
${QET_DIR}/sources/palettegraphicsview.cpp
${QET_DIR}/sources/palettegraphicsview.h
${QET_DIR}/sources/qetpalette.cpp
${QET_DIR}/sources/qetpalette.h
${QET_DIR}/sources/qetstyle.cpp
@@ -310,6 +312,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/dataBase/projectdatabase.cpp
${QET_DIR}/sources/dataBase/projectdatabase.h
${QET_DIR}/sources/dataBase/sqlreadonly.cpp
${QET_DIR}/sources/dataBase/sqlreadonly.h
${QET_DIR}/sources/dataBase/ui/elementquerywidget.cpp
${QET_DIR}/sources/dataBase/ui/elementquerywidget.h
@@ -505,10 +509,6 @@ set(QET_SRC_FILES
${QET_DIR}/sources/PropertiesEditor/propertieseditorwidget.cpp
${QET_DIR}/sources/PropertiesEditor/propertieseditorwidget.h
${QET_DIR}/pugixml/src/pugiconfig.hpp
${QET_DIR}/pugixml/src/pugixml.cpp
${QET_DIR}/pugixml/src/pugixml.hpp
${QET_DIR}/sources/qetgraphicsitem/conductor.cpp
${QET_DIR}/sources/qetgraphicsitem/conductor.h
${QET_DIR}/sources/qetgraphicsitem/conductortextitem.cpp
@@ -705,6 +705,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ui/conductorpropertiesdialog.h
${QET_DIR}/sources/ui/conductorcolortoolbutton.cpp
${QET_DIR}/sources/ui/conductorcolortoolbutton.h
${QET_DIR}/sources/ui/diagrambgcolorbutton.cpp
${QET_DIR}/sources/ui/diagrambgcolorbutton.h
${QET_DIR}/sources/ui/conductorpropertieswidget.cpp
${QET_DIR}/sources/ui/conductorpropertieswidget.h
${QET_DIR}/sources/ui/configsaveloaderwidget.cpp
@@ -721,6 +723,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ui/backupdialog.h
${QET_DIR}/sources/ui/dialogwaiting.cpp
${QET_DIR}/sources/ui/dialogwaiting.h
${QET_DIR}/sources/ui/duplicateoffsetdialog.cpp
${QET_DIR}/sources/ui/duplicateoffsetdialog.h
${QET_DIR}/sources/ui/dynamicelementtextitemeditor.cpp
${QET_DIR}/sources/ui/dynamicelementtextitemeditor.h
${QET_DIR}/sources/ui/dynamicelementtextmodel.cpp
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<!-- keyboard body with a lighter top edge -->
<rect x="6" y="30" width="116" height="68" rx="8" fill="#31363b"/>
<rect x="6" y="30" width="116" height="34" rx="8" fill="#4d4d4d"/>
<rect x="6" y="46" width="116" height="18" fill="#31363b"/>
<!-- key rows -->
<g fill="#eff0f1">
<rect x="14" y="38" width="8" height="8" rx="1.5"/><rect x="24" y="38" width="8" height="8" rx="1.5"/>
<rect x="34" y="38" width="8" height="8" rx="1.5"/><rect x="44" y="38" width="8" height="8" rx="1.5"/>
<rect x="54" y="38" width="8" height="8" rx="1.5"/><rect x="64" y="38" width="8" height="8" rx="1.5"/>
<rect x="74" y="38" width="8" height="8" rx="1.5"/><rect x="84" y="38" width="8" height="8" rx="1.5"/>
<rect x="94" y="38" width="8" height="8" rx="1.5"/><rect x="104" y="38" width="10" height="8" rx="1.5"/>
<rect x="14" y="50" width="12" height="8" rx="1.5"/><rect x="28" y="50" width="8" height="8" rx="1.5"/>
<rect x="38" y="50" width="8" height="8" rx="1.5"/><rect x="48" y="50" width="8" height="8" rx="1.5"/>
<rect x="58" y="50" width="8" height="8" rx="1.5"/><rect x="68" y="50" width="8" height="8" rx="1.5"/>
<rect x="78" y="50" width="8" height="8" rx="1.5"/><rect x="88" y="50" width="8" height="8" rx="1.5"/>
<rect x="98" y="50" width="16" height="8" rx="1.5"/>
<rect x="14" y="62" width="16" height="8" rx="1.5"/><rect x="32" y="62" width="8" height="8" rx="1.5"/>
<rect x="42" y="62" width="8" height="8" rx="1.5"/><rect x="52" y="62" width="8" height="8" rx="1.5"/>
<rect x="62" y="62" width="8" height="8" rx="1.5"/><rect x="72" y="62" width="8" height="8" rx="1.5"/>
<rect x="82" y="62" width="8" height="8" rx="1.5"/><rect x="92" y="62" width="22" height="8" rx="1.5"/>
<rect x="14" y="74" width="10" height="8" rx="1.5"/><rect x="26" y="74" width="10" height="8" rx="1.5"/>
<rect x="38" y="74" width="52" height="8" rx="1.5"/>
<rect x="92" y="74" width="10" height="8" rx="1.5"/><rect x="104" y="74" width="10" height="8" rx="1.5"/>
</g>
<!-- the two keys of a shortcut, pressed -->
<rect x="14" y="62" width="16" height="8" rx="1.5" fill="#3daee9"/>
<rect x="42" y="62" width="8" height="8" rx="1.5" fill="#3daee9"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<!-- DIN rail -->
<rect x="6" y="86" width="116" height="18" rx="3" fill="#7f8c8d"/>
<rect x="6" y="92" width="116" height="6" fill="#4d4d4d"/>
<!-- six terminal blocks: L, L, N, PE, L, spare -->
<g fill="#eff0f1">
<rect x="12" y="26" width="16" height="66" rx="2"/>
<rect x="30" y="26" width="16" height="66" rx="2"/>
<rect x="48" y="26" width="16" height="66" rx="2"/>
<rect x="66" y="26" width="16" height="66" rx="2"/>
<rect x="84" y="26" width="16" height="66" rx="2"/>
<rect x="102" y="26" width="16" height="66" rx="2"/>
</g>
<!-- right-hand shading of each block -->
<g fill="#bdc3c7">
<rect x="24" y="28" width="4" height="62"/>
<rect x="42" y="28" width="4" height="62"/>
<rect x="60" y="28" width="4" height="62"/>
<rect x="78" y="28" width="4" height="62"/>
<rect x="96" y="28" width="4" height="62"/>
<rect x="114" y="28" width="4" height="62"/>
</g>
<!-- colored marker bands -->
<rect x="12" y="26" width="16" height="10" rx="2" fill="#7f8c8d"/>
<rect x="30" y="26" width="16" height="10" rx="2" fill="#7f8c8d"/>
<rect x="48" y="26" width="16" height="10" rx="2" fill="#3daee9"/>
<rect x="66" y="26" width="16" height="10" rx="2" fill="#27ae60"/>
<rect x="70" y="26" width="8" height="10" fill="#fdbc4b"/>
<rect x="84" y="26" width="16" height="10" rx="2" fill="#7f8c8d"/>
<rect x="102" y="26" width="16" height="10" rx="2" fill="#da4453"/>
<!-- screws -->
<g fill="#31363b">
<circle cx="20" cy="50" r="4.5"/><circle cx="20" cy="74" r="4.5"/>
<circle cx="38" cy="50" r="4.5"/><circle cx="38" cy="74" r="4.5"/>
<circle cx="56" cy="50" r="4.5"/><circle cx="56" cy="74" r="4.5"/>
<circle cx="74" cy="50" r="4.5"/><circle cx="74" cy="74" r="4.5"/>
<circle cx="92" cy="50" r="4.5"/><circle cx="92" cy="74" r="4.5"/>
<circle cx="110" cy="50" r="4.5"/><circle cx="110" cy="74" r="4.5"/>
</g>
<g fill="#eff0f1">
<rect x="16" y="49" width="8" height="2"/><rect x="16" y="73" width="8" height="2"/>
<rect x="34" y="49" width="8" height="2"/><rect x="34" y="73" width="8" height="2"/>
<rect x="52" y="49" width="8" height="2"/><rect x="52" y="73" width="8" height="2"/>
<rect x="70" y="49" width="8" height="2"/><rect x="70" y="73" width="8" height="2"/>
<rect x="88" y="49" width="8" height="2"/><rect x="88" y="73" width="8" height="2"/>
<rect x="106" y="49" width="8" height="2"/><rect x="106" y="73" width="8" height="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -216,6 +216,7 @@ Clarification:
ico/128x128/diagram.png by the QElectroTech team (License CC BY-ND 3.0)
ico/128x128/document-export.png by the QElectroTech team (License CC BY-ND 3.0)
ico/128x128/project.png by the QElectroTech team (License CC BY-ND 3.0)
ico/128x128/terminalstrip.png and configure-shortcuts.png by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), rendered from the .svg files beside them
ico/scalable/pdf-import.svg by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), laid out like ico/22x22/insert-image.png
ico/scalable/diagram.svg, folio-new.svg, folio-delete.svg, folio-properties.svg, label.svg by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), the folio icons redrawn in the same style
ico/256x256/* by Nuri from the QElectroTech team (License CC BY-ND 3.0)
+4
View File
@@ -259,12 +259,14 @@
<file alias="themes/qet/48x48/user-away-extended.png">48x48/user-away-extended.png</file>
<file alias="themes/qet/48x48/user-away.png">48x48/user-away.png</file>
<file alias="themes/qet/48x48/view-pim-journal.png">48x48/view-pim-journal.png</file>
<file alias="themes/qet/128x128/configure-shortcuts.png">128x128/configure-shortcuts.png</file>
<file alias="themes/qet/128x128/diagram.png">128x128/diagram.png</file>
<file alias="themes/qet/128x128/document-export.png">128x128/document-export.png</file>
<file alias="themes/qet/128x128/plasmagik.png">128x128/plasmagik.png</file>
<file alias="themes/qet/128x128/printer.png">128x128/printer.png</file>
<file alias="themes/qet/128x128/project.png">128x128/project.png</file>
<file alias="themes/qet/128x128/settings.png">128x128/settings.png</file>
<file alias="themes/qet/128x128/terminalstrip.png">128x128/terminalstrip.png</file>
<file alias="themes/qet/scalable/diagram.svg">scalable/diagram.svg</file>
<file alias="themes/qet/scalable/folio-delete.svg">scalable/folio-delete.svg</file>
<file alias="themes/qet/scalable/folio-new.svg">scalable/folio-new.svg</file>
@@ -469,5 +471,7 @@
<file>themes/qet-dark/scalable/ellipse-to-bezier.svg</file>
<file>themes/qet-dark/scalable/rect-to-bezier.svg</file>
<file>themes/qet-dark/scalable/rect-to-polyline.svg</file>
<file alias="themes/qet-dark/128x128/document-export.png">128x128/document-export.png</file>
<file alias="themes/qet-dark/128x128/printer.png">128x128/printer.png</file>
</qresource>
</RCC>
+50
View File
@@ -34,6 +34,16 @@ expects, so QIcon::fromTheme("name") finds them. The dark theme only holds
the icons that need a dark variant: black line art. Colored icons are not
touched; the dark theme inherits them from the light one.
Qt inherits by name, not by size: once a name has any file in the dark
theme, the parent theme is never consulted for that name, and a size the
dark theme lacks is served by scaling the nearest dark file. An icon that
is line art at 22 pixels and colored at 128 (the printer) would then come
out as the 22 pixel copy scaled up on a dark palette. So for every name
the dark theme holds, the .qrc also aliases the light files of the sizes
the dark theme does not have, when they read on the dark window (3:1,
measured as tests/qttest/tst_qeticons.cpp does); a light file that does
not is left out, and Qt scales the nearest dark copy as before.
An icon counts as line art when fewer than 20% of its visible pixels are
saturated. Its dark copy keeps hue and alpha and inverts lightness, scaled
so pure black becomes (220, 220, 220), the dark palette's text color.
@@ -101,6 +111,8 @@ WHITE_LIGHTNESS = 0.85 # a visible pixel this light counts as white
WHITE_FRACTION = 0.30 # at or above this an icon is "light art"
INK = 220 / 255.0 # lightness of pure black after inversion
SVG_INK = "#dcdcdc"
DARK_WINDOW = (53, 53, 53) # QET::Palette::fusionDark() window color
DARK_RATIO = 3.0 # what tst_qeticons requires of a dark theme file
def visible_pixels(image):
@@ -131,6 +143,28 @@ def has_light_fill(image):
return white / len(pixels) >= WHITE_FRACTION
def relative_luminance(rgb):
def linear(c):
c /= 255.0
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
def reads_on_dark(image):
"""True when the icon's mean visible color reaches DARK_RATIO against
the dark window, the test tst_qeticons applies to every dark file."""
pixels = visible_pixels(image)
if not pixels:
return False
n = len(pixels)
mean = (sum(p[0] for p in pixels) // n, sum(p[1] for p in pixels) // n,
sum(p[2] for p in pixels) // n)
lighter, darker = sorted((relative_luminance(mean), relative_luminance(DARK_WINDOW)),
reverse=True)
return (lighter + 0.05) / (darker + 0.05) >= DARK_RATIO
def invert_lightness(image):
"""Invert lightness, keeping hue, saturation and alpha.
@@ -240,6 +274,20 @@ def main():
changed += write_if_changed(target, text)
dark.append(f"themes/qet-dark/scalable/{name}")
# Complete each dark name with the light files of its other sizes
# that read on a dark window (see the module docstring): aliases, no
# copies.
dark_sizes = {}
for path in dark:
size, name = path.split("/")[2:]
dark_sizes.setdefault(name, set()).add(size)
dark_aliases = []
for alias, source in light:
size, name = alias.split("/")[2:]
if name in dark_sizes and size not in dark_sizes[name] \
and reads_on_dark(Image.open(ICO / source).convert("RGBA")):
dark_aliases.append((f"themes/qet-dark/{size}/{name}", source))
# Drop dark files from an earlier run that are no longer generated, so
# a reclassified icon falls back to the light theme instead of keeping
# a stale copy.
@@ -264,6 +312,8 @@ def main():
qrc.append(f' <file alias="{alias}">{source}</file>')
for path in dark:
qrc.append(f" <file>{path}</file>")
for alias, source in dark_aliases:
qrc.append(f' <file alias="{alias}">{source}</file>')
qrc += [" </qresource>", "</RCC>", ""]
changed += write_if_changed(QRC, "\n".join(qrc))
+349
View File
@@ -0,0 +1,349 @@
# qet-mcp — a Model Context Protocol server for QElectroTech projects
A small stdio MCP server that lets an AI assistant read and verify
QElectroTech projects: what is in a project, what an edit actually
changed, and what a whole corpus of projects contains.
It has **no third-party dependencies** — Python 3.9+ and the standard
library only. The MCP SDK is not required.
## Why
Verifying a change by screenshot is unreliable, and this tool exists
because that unreliability produced two wrong conclusions in one review
session:
- A drag of a multi-element selection *looked* like it had left the
symbols behind and detached their labels. Diffing the saved file showed
all four elements had moved by an identical `(0, -80)` and **no label
had moved at all**. A bug report was one step away from being filed.
- An "Apply" button *looked* like it did nothing. It was disabled,
because a required field was empty.
Both times the pixels misled and the model told the truth. So the tools
here read the model.
## Tools
| Tool | What it answers |
|---|---|
| `qet_project_info` | title, format version, folios, element and conductor counts |
| `qet_elements` | placed elements: uuid, type, position, label, information bag |
| `qet_conductors` | conductors and their documentation fields; filter by attribute |
| `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips |
| `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute |
| `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts |
| `qet_export` | run a headless export (pdf, png, svg, bom, cables, wires, wiring, nets, links, info) |
| `qet_edit` | **change a project** — place, move, rotate, label, wire, number, cross-reference, add text, shapes and images, restyle a symbol's text fields, delete; then diff the result |
| `qet_element_build` | **author a `.elmt`** — draw a new symbol, with terminals to wire it by |
| `qet_project_new` | **start from nothing** — an empty project with a title and folios |
| `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count |
| `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios |
| `qet_query` | **ask the project database** — read-only SQL over the views and tables |
`qet_export` and `qet_edit` launch QElectroTech. Everything else parses the
file directly, which is faster, needs no display, and cannot be confused by
a dialog.
## Running it
```bash
# list the tools and exit
misc/qet-mcp/qet_mcp.py --list
# speak MCP on stdin/stdout
misc/qet-mcp/qet_mcp.py
```
Register it with an MCP client, for example:
```json
{
"mcpServers": {
"qet": {
"command": "python3",
"args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"],
"env": {
"QET_MCP_WORKSPACE": "/home/you/drawings",
"QET_ENABLE_SCRIPTING": "1"
}
}
}
}
```
## Five tools need scripting switched on
A QElectroTech with JavaScript scripting switched off refuses `--run`, and
off is the default from
[#984](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/984)
onwards. Five tools here drive it that way and stop working until it is
turned on:
| | |
|---|---|
| need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_project_new`, `qet_edit` |
| unaffected | everything else — they read the `.qet` directly, or, in `qet_export`'s case, use a plain CLI flag |
The variable goes in the environment this server is started in, which for an
MCP client is the `env` block above; the server passes its environment
straight through to QElectroTech. It does not set the variable itself, on
purpose — a switch a program turns on for itself is not a switch. Whoever
configured this server and pointed it at a QElectroTech binary made that
choice, and their interactive QElectroTech keeps whatever its own setting
says.
Without it, those five come back `"ok": false` with a `hint` naming the
variable. Older builds, from before the setting existed, need nothing.
## What the server is allowed to touch
Every path in a tool call is chosen by the model, so without a policy this
server would be a read/write primitive for anything the operating system
lets the process reach: read any project on the disk, export one somewhere
else, overwrite an unrelated file, embed an arbitrary local image or PDF.
So **data paths are confined to a workspace**:
| | |
|---|---|
| `QET_MCP_WORKSPACE` | the directories tool calls may read and write, separated by `:` (`;` on Windows) |
| unset | the directory the server was started in |
| `QET_MCP_ALLOW_ANY_PATH=1` | turns the check off entirely |
Set the workspace to the folder your drawings live in. A path outside it is
refused with an error naming what was allowed; symlinks are resolved first,
so a link planted inside the workspace is judged by where it points.
Two arguments are deliberately **not** confined: `binary` (the
`qelectrotech` executable) and `elements_dir` (the element collection).
Those are configuration, chosen once by whoever runs the server, and both
normally live in `/usr` or a build tree — outside any sensible workspace.
Confining them would reject the ordinary case while stopping nothing.
`QET_MCP_ALLOW_ANY_PATH=1` is equivalent to granting the client local
filesystem access with this process's privileges. It exists so that is a
deliberate choice rather than the default.
**Nothing is overwritten unasked.** `qet_export`, `qet_edit`,
`qet_project_new` and `qet_element_build` refuse an `output` that already
exists unless the call passes `"overwrite": true`. Replacing a file is the
one step this server cannot undo, so it is the one step it will not take on
its own.
The confinement is applied where tool arguments enter the server, not inside
each tool. Importing `qet_mcp` and calling `tool_export()` from your own
Python is not confined and is not meant to be — that is your code calling a
library, and you already chose the paths.
## Worked examples
**What did that edit change?**
```json
{"name": "qet_diff", "arguments": {"before": "a.qet", "after": "b.qet"}}
```
```json
"elements": { "moved_count": 4,
"distinct_move_deltas": [[0.0, -80.0]],
"relabelled": [], "info_changed": [] }
```
Four elements moved by one uniform delta; nothing was relabelled. That is
the answer a screenshot gave wrongly.
**Draw something, and check it landed**
```json
{"name": "qet_edit", "arguments": {
"binary": "/path/to/qelectrotech",
"project": "in.qet", "output": "out.qet",
"elements_dir": "/path/to/qelectrotech/elements",
"operations": [
{"op": "add_folio", "id": "f"},
{"op": "set_folio_title", "folio": "$f", "title": "Starter"},
{"op": "add_element", "id": "k1", "folio": "$f", "path": "common://.../coil.elmt", "x": 100, "y": 100},
{"op": "add_element", "id": "k2", "folio": "$f", "path": "common://.../coil.elmt", "x": 320, "y": 100},
{"op": "add_conductor", "folio": "$f", "from": "$k1", "from_terminal": 0, "to": "$k2", "to_terminal": 0},
{"op": "set_conductor", "folio": "$f", "element": "$k1", "terminal": 0, "property": "num", "value": "W7"},
{"op": "set_label", "folio": "$f", "element": "$k1", "label": "KM1"}
]}}
```
An op that creates something takes an `"id"`; later ops name it as `"$id"`.
Terminals are addressed by index — top to bottom, then left to right, **not**
the order the `.elmt` lists them. `qet_element_info` and `qet_element_search`
both report that index order. The answer carries a per-operation result
*and* a `qet_diff`, because "addConductor → true" says the call was
accepted, not that the file came out right:
```json
"diff": {"elements": {"before": 11, "after": 13, "added": ["{0aa3…}", "{6f63…}"]},
"conductors": {"before": 47, "after": 48, "added": ["4:{0aa3…}/{2904…}--{6f63…}/{2904…}"],
"removed": []}}
```
**Draw a symbol that does not exist yet**
```json
{"name": "qet_element_build", "arguments": {
"output": "/path/to/collection/99_custom/my_resistor.elmt",
"names": {"en": "Test resistor", "fr": "Résistance de test"},
"parts": [
{"type": "rect", "x": -10, "y": -20, "width": 20, "height": 40},
{"type": "line", "x1": 0, "y1": -30, "x2": 0, "y2": -20},
{"type": "line", "x1": 0, "y1": 20, "x2": 0, "y2": 30},
{"type": "text", "x": 14, "y": -4, "text": "R"}
],
"terminals": [{"x": 0, "y": -30, "orientation": "n", "name": "1"},
{"x": 0, "y": 30, "orientation": "s", "name": "2"}]}}
```
Then place it with `qet_edit` like any catalogue element. Unlike a
project, a `.elmt` is not rewritten by QElectroTech on a round trip, so
generating one here is safe in a way that generating a `.qet` would not
be — there is no `toXml()` waiting to drop what this writer did not know
to emit.
**Ask a question the XML cannot answer**
```json
{"name": "qet_query", "arguments": {
"binary": "/path/to/qelectrotech", "project": "industrial.qet",
"sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}}
```
```json
"rows": [{"label": "V6", "n": 7}, {"label": "V5", "n": 6}, {"label": "V4", "n": 6}]
```
Duplicate element labels in a shipped example — a design-rule question,
answered by the database that already knew it.
**How much of a corpus uses a field?**
```json
{"name": "qet_scan",
"arguments": {"directory": "examples", "tag": "conductor", "attribute": "cable"}}
```
```json
{ "files": 24, "total": 3190, "non_empty": 0, "distinct_values": [] }
```
Across the shipped examples: 3190 conductors, not one with a cable value.
## Testing
```bash
python3 test_qet_mcp.py # unit + protocol, no QElectroTech needed
QET_BINARY=/path/to/qelectrotech \
QET_ELEMENTS=/path/to/qelectrotech/elements \
QET_EXAMPLES=/path/to/qelectrotech/examples \
QET_ENABLE_SCRIPTING=1 \
python3 test_qet_mcp.py # everything
```
`QET_ENABLE_SCRIPTING=1` matters from #984 onwards: without it the
integration tests that drive QElectroTech through a script all fail, and
they fail as "the edit did nothing" rather than as "scripting is off", which
reads like a regression in the thing under test.
176 tests in three layers: unit (validation, script generation, the terminal
order rule, the diff, the part schema), the real stdio transport, and
integration against a built QElectroTech. Several exist because the
behaviour they pin was once wrong and looked right, and say so in their
docstrings. To check the suite itself rather than trust it, each of those
bugs was reintroduced in turn and the suite confirmed to fail: ten in the
Python, plus the hang guard on `addConductor` and the database refresh in
`ConductorCreator` in the C++.
## Notes and limits
- **The project database is reachable now, through `qet_query`.** It was
not when this server was written, which is why every other structural
tool here re-derives its answer from the XML. Prefer the views —
`element_nomenclature_view`, `project_summary_view`, `wiring_list_view`
— which exist to be queried; the underlying tables are how the cache is
arranged today and a column may move. Call `qet_query` with no `sql` to
list both. Only `SELECT` and `WITH` are accepted, which is the rule
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.
- **`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*
process's answer with no error. The tool copies the binary to a unique
temporary path, gives it a private `HOME`, and runs it on the offscreen
platform. A symlink would not work: `applicationFilePath()` resolves it
back to the real path.
- **The CLI matches its flags exactly.** `--export-bom out.csv` is the
supported form; `--export-bom=out.csv` is not recognised as an export
at all, so the application starts its interface instead and a headless
run hangs. The tool uses the positional form.
- **Conductor identity is the hard part of `qet_diff`.** A conductor names
its ends with `terminal1`/`terminal2`, and the project format has two
schemes: folio-scoped integer ids in older files, terminal-definition
uuids plus `element1`/`element2` in newer ones. The integer ids are
**renumbered on every save**, so keying on them — which this tool did at
first — made all 47 conductors of an untouched folio read as removed and
re-added the moment the other side had been through QElectroTech, which
is exactly what `qet_edit` produces. They are now keyed by owning element
uuid plus terminal, which is stable across a save: measured at 0 colliding
keys over 3190 conductors in the 24 shipped examples, and 0 churn on a
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
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,
which makes a move in such a file read as a remove plus an add.
- **`qet_edit` needs a build whose scripting API carries the drawing verbs.**
Against an older one it reports exactly which methods are missing and
changes nothing. `addElement` and the move/delete verbs shipped with the
scripting API; `addConductor`, `rotateElement`, `setElementLabel`,
`setElementInfo` and `setFolioTitle` are newer.
- **`elements_dir` is not optional for `common://` paths.** The sandboxed
run has its own empty HOME, so QElectroTech falls back to the compiled-in
collection path, which on a machine that never ran `make install` does not
exist. The only symptom is `addElement` reporting that a file plainly
present "does not resolve to an element". An absolute `.elmt` path works
without it.
- **`set_conductor` changes the whole potential, not one segment.** That is
what the application does — a wire number describes a potential — so name
a terminal carrying exactly one conductor and the change reaches every
conductor electrically joined to it. A terminal several conductors meet
at names none of them and is refused, so address a potential from one of
its leaves. Property names are the file's own, so `qet_conductors` reads
back exactly what was set.
- **`link_elements` takes a folio for each end**, because a master and its
slave are normally on different folios. Whether a pair may be linked is
decided by QElectroTech's own `isLinkable()`, so a script cannot make a
link the GUI would refuse.
- **An element must live inside a collection to be placeable.** This is
not about the path syntax: an absolute `.elmt` path works, but only if
the file sits under a directory QElectroTech knows as a collection.
Write it under the tree you pass as `elements_dir` and `qet_edit` can
place it, by absolute path or as `common://…`; write it anywhere else
and `add_element` reports only "does not resolve to an element".
- **`qet_element_build` computes the `.elmt` size header, and checks it.**
`width`/`height`/`hotspot_x`/`hotspot_y` relate to the drawing by a
containment constraint, not a formula — the declared box runs from
`(-hotspot_x, -hotspot_y)` to `(width - hotspot_x, height - hotspot_y)`
and the drawing must fit inside it. The shipped collection shows authors
picking their own margins (one element pads 2 units left and 3 right,
another 8 and 2), so there is no convention to copy, only an invariant
to satisfy. A drawing that escaped its box is the classic way a
hand-written element renders clipped in the collection panel while
looking fine in XML.
- **QElectroTech interrupts a script at 30 s** of its own accord, separately
from this tool's `timeout`. A very long operation list will hit that
first.
- **`qet_edit` never writes the input.** It saves to a separate file and
diffs the two, so the original is always the thing the diff is against.
@@ -0,0 +1,44 @@
<definition version="0.100.0" type="element" link_type="master" width="40" height="60" hotspot_x="17" hotspot_y="32">
<uuid uuid="{6d3714f6-a0e1-71b3-e4bd-5a35d4cbbec1}"/>
<names>
<name lang="ar">ملف KA بمحتفظ مغناطيسي</name>
<name lang="ca">Bobina</name>
<name lang="cs">Bistabilní remanentní relé</name>
<name lang="de">Remanenzrelais</name>
<name lang="el">Πηνίο με μανδάλωση</name>
<name lang="en">Coil</name>
<name lang="es">Bobina KA de remanencia</name>
<name lang="fr">Bobine KA à rémanence</name>
<name lang="hu">Tekercs</name>
<name lang="it">Bobina</name>
<name lang="nl">spoel remanent</name>
<name lang="nl_BE">Spoel KA remanent</name>
<name lang="pl">Cewka przekaźnika remanencyjnego</name>
<name lang="pt_BR">Bobina</name>
<name lang="ru">Обмотка</name>
<name lang="zh">剩磁保持线圈</name>
</names>
<kindInformations>
<kindInformation name="type">plc</kindInformation>
</kindInformations>
<informations>Author: The QElectroTech team
License: see http://qelectrotech.org/wiki/doc/elements_license</informations>
<description>
<rect x="14" y="-8" width="6" height="16" rx="0" ry="0" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<line x1="0" y1="-20" x2="0" y2="-8" end1="none" end2="none" length1="1.5" length2="1.5" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<dynamic_text x="2" y="11.33" z="5" text_width="-1" Halignment="AlignLeft" Valignment="AlignTop" frame="false" rotation="0" keep_visual_rotation="false" text_from="UserText" uuid="{647e33ed-520b-4d80-bbc4-7b31177b8f26}" font="Liberation Sans,4,-1,5,25,0,0,0,0,0,Regular">
<text>A2</text>
</dynamic_text>
<dynamic_text x="2" y="-24.67" z="6" text_width="-1" Halignment="AlignLeft" Valignment="AlignTop" frame="false" rotation="0" keep_visual_rotation="false" text_from="UserText" uuid="{fb1f8de9-70c0-4801-8dc7-12e6bf88d8f5}" font="Liberation Sans,4,-1,5,25,0,0,0,0,0,Regular">
<text>A1</text>
</dynamic_text>
<line x1="0" y1="8" x2="0" y2="20" end1="none" end2="none" length1="1.5" length2="1.5" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<dynamic_text x="30" y="-9.17" z="8" text_width="-1" Halignment="AlignLeft" Valignment="AlignTop" frame="false" rotation="0" keep_visual_rotation="false" text_from="ElementInfo" uuid="{b7fccfc3-05f1-459c-9766-f49a481bd0ff}" font="Liberation Sans,9,-1,5,50,0,0,0,0,0,Regular">
<text></text>
<info_name>label</info_name>
</dynamic_text>
<polygon x1="-14" y1="-8" x2="14" y2="-8" x3="20" y3="8" x4="-14" y4="8" antialias="false" style="line-style:normal;line-weight:normal;filling:none;color:black"/>
<terminal uuid="{6a87c921-6f5d-4f5c-8673-228a6d13c5c8}" name="A2" x="0" y="20" orientation="s" type="Generic"/>
<terminal uuid="{2904e5fa-6bbe-4127-acdd-f92feadd6ece}" name="A1" x="0" y="-20" orientation="n" type="Generic"/>
</description>
</definition>
+16
View File
@@ -0,0 +1,16 @@
<definition version="0.100.0" type="element" link_type="slave" width="20" height="60" hotspot_x="9" hotspot_y="30">
<uuid uuid="{94d56f06-6814-5561-9148-2da3a9c4f900}"/>
<names>
<name lang="en">PLC Slave Test</name>
</names>
<kindInformations>
<kindInformation name="type">plc</kindInformation>
<kindInformation name="state">NO</kindInformation>
<kindInformation name="number">1</kindInformation>
</kindInformations>
<description>
<rect x="0" y="-8" width="6" height="16" rx="0" ry="0" style="line-style:normal;line-weight:normal;filling:none;color:black" antialias="false"/>
<terminal uuid="{6a87c921-6f5d-4f5c-8673-228a6d13c500}" name="1" x="0" y="20" orientation="s" type="Generic"/>
<terminal uuid="{2904e5fa-6bbe-4127-acdd-f92feadd6e00}" name="2" x="0" y="-20" orientation="n" type="Generic"/>
</description>
</definition>
+2773
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
#Based on raspberry pi 5 8 Gb Trixie
#sudo apt install git ssh rsync libqt5svg5-dev qt5-qmake qtbase5-dev libkf5widgetsaddons-dev libkf5coreaddons-dev libsqlite3-dev pkgconf libqt5waylandclient5-dev libqt5waylandcompositor5-dev g++ make
#sudo apt install git ssh rsync libqt5svg5-dev qt5-qmake qtbase5-dev libkf5widgetsaddons-dev libkf5coreaddons-dev pkgconf libqt5waylandclient5-dev libqt5waylandcompositor5-dev g++ make
#mkdir -p AppImage/0.100.0/aarch64
# Get GIT sources
#git clone --recursive https://github.com/qelectrotech/qelectrotech-source-mirror.git
@@ -20,7 +20,7 @@
#include "../NameList/nameslist.h"
#include "../diagramcontext.h"
#include "pugixml/src/pugixml.hpp"
#include "pugixml.hpp"
#include <QIcon>
#include <QString>
+1 -1
View File
@@ -17,7 +17,7 @@
*/
#ifndef NAMES_LIST_H
#define NAMES_LIST_H
#include "pugixml/src/pugixml.hpp"
#include "pugixml.hpp"
#include <QtXml>
/**
@@ -35,13 +35,17 @@ AddTerminalToStripCommand::AddTerminalToStripCommand(QSharedPointer<RealTerminal
const auto t_label = terminal->label();
const auto ts_name = strip->name();
const auto str_1 = t_label.isEmpty() ? QObject::tr("Ajouter une borne") :
QObject::tr("Ajouter la borne %1").arg(t_label);
const auto str_2 = ts_name.isEmpty() ? QObject::tr("à un groupe de bornes") :
QObject::tr("au groupe de bornes %1").arg(ts_name);
setText(str_1 % " " % str_2);
QString text;
if (ts_name.isEmpty()) {
text = t_label.isEmpty()
? QObject::tr("Ajouter une borne à un groupe de bornes")
: QObject::tr("Ajouter la borne %1 à un groupe de bornes").arg(t_label);
} else {
text = t_label.isEmpty()
? QObject::tr("Ajouter une borne au groupe de bornes %1").arg(ts_name)
: QObject::tr("Ajouter la borne %1 au groupe de bornes %2").arg(t_label, ts_name);
}
setText(text);
}
AddTerminalToStripCommand::AddTerminalToStripCommand(QVector<QSharedPointer<RealTerminal>> terminals, TerminalStrip *strip, QUndoCommand *parent) :
@@ -50,14 +54,11 @@ AddTerminalToStripCommand::AddTerminalToStripCommand(QVector<QSharedPointer<Real
m_new_strip{strip}
{
const auto ts_name = strip->name();
const auto count = m_terminal.size();
const auto str_1 = m_terminal.size() > 1 ? QObject::tr("Ajouter %1 bornes").arg(m_terminal.size()) :
QObject::tr("Ajouter une borne");
const auto str_2 = ts_name.isEmpty() ? QObject::tr("à un groupe de bornes") :
QObject::tr("au groupe de bornes %1").arg(ts_name);
setText(str_1 % " " % str_2);
setText(ts_name.isEmpty()
? QObject::tr("Ajouter %n borne(s) à un groupe de bornes", "", count)
: QObject::tr("Ajouter %n borne(s) au groupe de bornes %1", "", count).arg(ts_name));
}
@@ -137,13 +138,11 @@ void RemoveTerminalFromStripCommand::redo()
void RemoveTerminalFromStripCommand::setCommandTitle()
{
const auto strip_name = m_strip->name();
const auto count = m_terminals.size();
const auto str_1 = m_terminals.size()>1 ? QObject::tr("Enlever %1 bornes").arg(m_terminals.size()):
QObject::tr("Enlever une borne");
const auto str_2 = strip_name.isEmpty() ? QObject::tr("d'un groupe de bornes") :
QObject::tr("du groupe de bornes %1").arg(strip_name);
setText(str_1 % " " % str_2);
setText(strip_name.isEmpty()
? QObject::tr("Enlever %n borne(s) d'un groupe de bornes", "", count)
: QObject::tr("Enlever %n borne(s) du groupe de bornes %1", "", count).arg(strip_name));
}
/**
@@ -166,19 +165,30 @@ MoveTerminalCommand::MoveTerminalCommand(QSharedPointer<PhysicalTerminal> termin
t_label.append(", ");
t_label.append(real_t->label());
}
const auto strip_name = old_strip->name();
const auto new_strip_name = new_strip->name();
auto strip_name = old_strip->name();
auto new_strip_name = new_strip->name();
auto str_1 = t_label.isEmpty() ? QObject::tr("Déplacer une borne") :
QObject::tr("Déplacer la borne %1").arg(t_label);
auto str_2 = strip_name.isEmpty() ? QObject::tr(" d'un groupe de bornes") :
QObject::tr(" du groupe de bornes %1").arg(strip_name);
auto str_3 = new_strip_name.isEmpty() ? QObject::tr("vers un groupe de bornes") :
QObject::tr("vers le groupe de bornes %1").arg(new_strip_name);
setText(str_1 % " " % str_2 % " " % str_3);
QString text;
if (t_label.isEmpty()) {
if (strip_name.isEmpty() && new_strip_name.isEmpty())
text = QObject::tr("Déplacer une borne d'un groupe de bornes vers un autre groupe de bornes");
else if (strip_name.isEmpty())
text = QObject::tr("Déplacer une borne d'un groupe de bornes vers le groupe de bornes %1").arg(new_strip_name);
else if (new_strip_name.isEmpty())
text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers un autre groupe de bornes").arg(strip_name);
else
text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers le groupe de bornes %2").arg(strip_name, new_strip_name);
} else {
if (strip_name.isEmpty() && new_strip_name.isEmpty())
text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers un autre groupe de bornes").arg(t_label);
else if (strip_name.isEmpty())
text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers le groupe de bornes %2").arg(t_label, new_strip_name);
else if (new_strip_name.isEmpty())
text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers un autre groupe de bornes").arg(t_label, strip_name);
else
text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers le groupe de bornes %3").arg(t_label, strip_name, new_strip_name);
}
setText(text);
}
MoveTerminalCommand::MoveTerminalCommand(QVector<QSharedPointer<PhysicalTerminal>> terminals, TerminalStrip *old_strip,
@@ -191,17 +201,18 @@ MoveTerminalCommand::MoveTerminalCommand(QVector<QSharedPointer<PhysicalTerminal
{
const auto strip_name = old_strip->name();
const auto new_strip_name = new_strip->name();
const auto count = m_terminal.size();
const auto str_1 = m_terminal.size() > 1 ? QObject::tr("Déplacer des bornes") :
QObject::tr("Déplacer une borne");
const auto str_2 = strip_name.isEmpty() ? QObject::tr(" d'un groupe de bornes") :
QObject::tr(" du groupe de bornes %1").arg(strip_name);
const auto str_3 = new_strip_name.isEmpty() ? QObject::tr("vers un groupe de bornes") :
QObject::tr("vers le groupe de bornes %1").arg(new_strip_name);
setText(str_1 % " " % str_2 % " " % str_3);
QString text;
if (strip_name.isEmpty() && new_strip_name.isEmpty())
text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers un autre groupe de bornes", "", count);
else if (strip_name.isEmpty())
text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers le groupe de bornes %1", "", count).arg(new_strip_name);
else if (new_strip_name.isEmpty())
text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers un autre groupe de bornes", "", count).arg(strip_name);
else
text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers le groupe de bornes %2", "", count).arg(strip_name, new_strip_name);
setText(text);
}
void MoveTerminalCommand::undo()
+77
View File
@@ -275,3 +275,80 @@ QString NumerotationContext::formatValue(const QStringList &item)
return QString("%1").arg(value.toInt(), 3, 10, QChar('0'));
return QString::number(value.toInt());
}
/**
@brief NumerotationContext::saveToSettings
Save a hash of named NumerotationContexts to QSettings.
@param contexts : the named rules to save
@param currentRule : the name of the currently active rule
@param settings : QSettings instance
@param prefix : settings key prefix (e.g. "autonum/conductor")
*/
void NumerotationContext::saveToSettings(
const QHash<QString, NumerotationContext> &contexts,
const QString &currentRule,
QSettings &settings,
const QString &prefix)
{
settings.setValue(prefix + "/current", currentRule);
// Clear stale array entries before writing (beginWriteArray does not
// remove entries beyond the new size).
settings.remove(prefix + "/rules");
QStringList names = contexts.keys();
settings.beginWriteArray(prefix + "/rules", names.size());
for (int i = 0; i < names.size(); ++i) {
settings.setArrayIndex(i);
const QString &name = names.at(i);
const NumerotationContext &nc = contexts.value(name);
settings.setValue("name", name);
// Serialize context to XML string
QDomDocument doc;
NumerotationContext nc_copy = nc;
QDomElement root = nc_copy.toXml(doc, "context");
doc.appendChild(root);
settings.setValue("xml", doc.toString());
}
settings.endArray();
}
/**
@brief NumerotationContext::loadFromSettings
Load named NumerotationContexts from QSettings.
@param settings : QSettings instance
@param prefix : settings key prefix (e.g. "autonum/conductor")
@return pair of (hash of named rules, name of current rule)
*/
QPair<QHash<QString, NumerotationContext>, QString> NumerotationContext::loadFromSettings(
QSettings &settings,
const QString &prefix)
{
QPair<QHash<QString, NumerotationContext>, QString> result;
QHash<QString, NumerotationContext> &contexts = result.first;
QString &currentRule = result.second;
currentRule = settings.value(prefix + "/current").toString();
int size = settings.beginReadArray(prefix + "/rules");
for (int i = 0; i < size; ++i) {
settings.setArrayIndex(i);
QString name = settings.value("name").toString();
QString xmlStr = settings.value("xml").toString();
if (name.isEmpty() || xmlStr.isEmpty()) continue;
QDomDocument doc;
if (!doc.setContent(xmlStr)) continue;
QDomElement root = doc.documentElement();
NumerotationContext nc;
nc.fromXml(root);
contexts.insert(name, nc);
}
settings.endArray();
return result;
}
+10
View File
@@ -21,6 +21,8 @@
#include <QStringList>
#include <QVariant>
#include <QDomElement>
#include <QHash>
#include <QSettings>
/**
This class represents a numerotation context, i.e. the data (type, value, increase)
@@ -60,6 +62,14 @@ class NumerotationContext
/// UI preview of a part's value matches what actually gets rendered.
static QString formatValue(const QStringList &item);
static void saveToSettings(const QHash<QString, NumerotationContext> &contexts,
const QString &currentRule,
QSettings &settings,
const QString &prefix);
static QPair<QHash<QString, NumerotationContext>, QString> loadFromSettings(
QSettings &settings,
const QString &prefix);
private:
QStringList content_;
};
+16 -3
View File
@@ -505,7 +505,10 @@ void BorderTitleBlock::draw(QPainter *painter)
{
//Set the QPainter
painter -> save();
QPen pen(Qt::black);
//Use a pen color that contrasts with the background
QColor border_color = Diagram::background_color.lightness() < 128
? QColor(Qt::white) : QColor(Qt::black);
QPen pen(border_color);
painter -> setPen(pen);
painter -> setBrush(Qt::NoBrush);
@@ -893,10 +896,20 @@ void BorderTitleBlock::updateDiagramContextForTitleBlock(
// An empty page-level value means the variable was auto-added to the
// folio's Custom tab (#495) but never actually set by the user, so it
// must not shadow a real project-level value of the same name (#531).
//
// That guard has to stop short of removing the key outright, though
// (#973). TitleBlockTemplate::interpreteVariables() only replaces a
// "%name"/"%{name}" placeholder when "name" is a key in this context at
// all -- an unset variable that never makes it in is left as its own
// literal placeholder text in the rendered title block, not blank.
// So an empty page-level value is skipped only when a real project-level
// one is already there to show through; otherwise it still goes in
// empty, which is what makes the placeholder resolve to nothing.
DiagramContext context = initial_context;
foreach (QString key, additional_fields_.keys()) {
if (!additional_fields_[key].toString().isEmpty())
context.addValue(key, additional_fields_[key]);
const QVariant value = additional_fields_[key];
if (!value.toString().isEmpty() || !context.contains(key))
context.addValue(key, value);
}
// ... overridden by the historical and/or dynamically generated fields
+13
View File
@@ -499,6 +499,7 @@ void ConductorProperties::applyForEqualAttributes(QList<ConductorProperties> lis
horiz_rotate_text = cp.horiz_rotate_text;
m_vertical_alignment = cp.m_vertical_alignment;
m_horizontal_alignment = cp.m_horizontal_alignment;
style = cp.style;
return;
}
@@ -555,6 +556,18 @@ void ConductorProperties::applyForEqualAttributes(QList<ConductorProperties> lis
m_dash_size = i_value;
equal = true;
//style
Qt::PenStyle pen_style;
pen_style = clist.first().style;
for(ConductorProperties cp : clist)
{
if (cp.style != pen_style)
equal = false;
}
if (equal)
style = pen_style;
equal = true;
//text
s_value = clist.first().text;
for(ConductorProperties cp : clist)
+44 -24
View File
@@ -17,6 +17,8 @@
*/
#include "projectdatabase.h"
#include "sqlreadonly.h"
#include "../diagram.h"
#include "../diagramposition.h"
#include "../elementprovider.h"
@@ -28,13 +30,15 @@
#include "../qetproject.h"
#include <QLocale>
#include <QFile>
#include <QRegularExpression>
#include <QSqlError>
#include <QSqlDriver>
#include <QSqlError>
#include <sqlite3.h>
/**
@brief projectDataBase::projectDataBase
Default constructor
@@ -208,6 +212,12 @@ bool projectDataBase::isReadOnlySelect(const QString &query, QString *error)
*/
QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
QString reason;
// 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
// keeps ATTACH, BEGIN and PRAGMA out, none of which SQLite itself
// considers writes.
if (!isReadOnlySelect(query, &reason)) {
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
if (error) {
@@ -215,6 +225,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)) {
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
if (error) {
*error = reason;
}
return QSqlQuery(m_data_base);
}
return QSqlQuery(query, m_data_base);
}
@@ -1245,7 +1273,6 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram)
}
}
#ifdef QET_EXPORT_PROJECT_DB
/**
@brief projectDataBase::sqliteHandle
@param db
@@ -1263,6 +1290,7 @@ sqlite3 *projectDataBase::sqliteHandle(QSqlDatabase *db)
return handle;
}
#ifdef QET_EXPORT_PROJECT_DB
/**
* @brief projectDataBase::exportDb
@@ -1298,27 +1326,19 @@ void projectDataBase::exportDb(projectDataBase *db,
return;
}
QString connection_name("export_project_db_" % db->project()->uuid().toString());
if (true) //Enter in a scope only to nicely use QSqlDatabase::removeDatabase just after the end of the scope
{
auto file_db = QSqlDatabase::addDatabase("QSQLITE", connection_name);
file_db.setDatabaseName(path_);
if (!file_db.open()) {
return;
}
auto memory_db_handle = sqliteHandle(&db->m_data_base);
auto file_db_handle = sqliteHandle(&file_db);
auto sqlite_backup = sqlite3_backup_init(file_db_handle, "main", memory_db_handle, "main");
if (sqlite_backup)
{
sqlite3_backup_step(sqlite_backup, -1);
sqlite3_backup_finish(sqlite_backup);
}
file_db.close();
// VACUUM INTO requires the destination not to exist. QFileDialog may ask
// about overwriting, but it does not remove the existing file for us.
if (QFile::exists(path_) && !QFile::remove(path_)) {
qWarning() << "Unable to replace project database export:" << path_;
return;
}
// 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("'", "''");
QSqlQuery query(db->m_data_base);
if (!query.exec("VACUUM INTO '" % escaped_path % "'")) {
qWarning() << "Unable to export project database:" << query.lastError().text();
}
QSqlDatabase::removeDatabase(connection_name);
}
#endif
+26 -1
View File
@@ -61,6 +61,26 @@ class projectDataBase : public QObject
QETProject *project() const;
QSqlQuery newQuery(const QString &query = QString(), QString *error = nullptr);
static bool isReadOnlySelect(const QString &query, QString *error = nullptr);
/**
The most rows any caller reads out of one query result.
A SELECT is not bounded by how much data the project holds:
SQLite produces rows lazily, so a query that never stops
producing them makes the loop that reads them never stop
either. A recursive CTE does exactly that in one line, and
a <graphics_table>'s <query> is stored in the .qet and run
on load -- so the text can arrive from a file rather than
from the person at the keyboard, and opening that file is
the whole attack.
100000 is far above any real result: the largest table in
the shipped examples is 396 rows. It is a backstop, not a
page size -- a caller that hits it has almost certainly
been handed something it should not run to completion, and
says so rather than truncating quietly.
*/
static constexpr int MaxResultRows = 100000;
QSqlDatabase database() const {return m_data_base;}
int excludedConductorCount() const;
@@ -135,9 +155,14 @@ class projectDataBase : public QObject
m_cascade_remove_conductor_query,
m_cascade_remove_element_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);
#ifdef QET_EXPORT_PROJECT_DB
public:
static sqlite3 *sqliteHandle(QSqlDatabase *db);
static void exportDb(projectDataBase *db,
QWidget *parent = nullptr,
const QString &caption = QString(),
+134
View File
@@ -0,0 +1,134 @@
/*
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 "sqlreadonly.h"
#include <QCoreApplication>
#include <sqlite3.h>
namespace QETSql {
/**
@brief QETSql::isSingleReadOnlyStatement
Ask SQLite itself whether @p query is exactly one statement, and whether
that statement only reads.
Why SQLite is asked rather than the text inspected: a check on the
query's first keyword cannot see what the statement actually does.
SQLite has allowed a CTE prefix in front of a data-modifying statement
since 3.8.3, so
@code
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.
The statement is compiled and immediately finalised; sqlite3_prepare_v2()
does not run it, so nothing is executed to reach this verdict.
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
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 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
*/
bool isSingleReadOnlyStatement(sqlite3 *handle, const QString &query, QString *error)
{
if (error) {
error->clear();
}
if (!handle) {
if (error) {
*error = QCoreApplication::translate("QETSql",
"Impossible de vérifier la requête : "
"aucune connexion SQLite disponible.");
}
return false;
}
const QByteArray utf8 = query.toUtf8();
sqlite3_stmt *statement = nullptr;
const char *tail = nullptr;
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;
}
// 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) {
*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;
}
}
return true;
}
} // namespace QETSql
+43
View File
@@ -0,0 +1,43 @@
/*
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 SQLREADONLY_H
#define SQLREADONLY_H
#include <QString>
struct sqlite3;
/**
Deciding whether a piece of SQL only 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
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);
}
#endif // SQLREADONLY_H
+10 -7
View File
@@ -26,6 +26,7 @@
#include "diagramposition.h"
#include "factory/elementfactory.h"
#include "qetapp.h"
#include "qetpalette.h"
#include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h"
#include "qetgraphicsitem/conductor.h"
#include "qetgraphicsitem/conductortextitem.h"
@@ -283,10 +284,11 @@ void Diagram::drawBackground(QPainter *p, const QRectF &r) {
* if background color is black,
* then grid spots shall be white,
* else they shall be black in color.
* A view that shows the sheet with its lightness inverted
* gets softer dots, see QET::Palette::gridDotColor.
*/
QPen pen;
Diagram::background_color == Qt::black? pen.setColor(Qt::white)
: pen.setColor(Qt::black);
pen.setColor(QET::Palette::gridDotColor(Diagram::background_color, m_inverted_lightness));
pen.setCosmetic(true);
p->setPen(pen);
@@ -1707,10 +1709,7 @@ bool Diagram::fromXml(QDomElement &document,
//Get the top left corner of the rectangle that contain all added items
QRectF items_rect;
for (auto item : added_items) {
items_rect = items_rect.united(
item->mapToScene(
item->boundingRect()
).boundingRect());
items_rect = items_rect.united(item->mapToScene(item->boundingRect()).boundingRect());
}
QPointF point_ = items_rect.topLeft();
@@ -1718,8 +1717,12 @@ bool Diagram::fromXml(QDomElement &document,
position.y() - point_.y()));
//Translate all added items
for (auto qgi : added_items)
for (auto qgi : added_items) {
qgi->setPos(qgi->pos() += pos_);
}
}
else
{
}
// Load conductor
+14
View File
@@ -123,6 +123,7 @@ class Diagram : public QGraphicsScene
qreal diagram_qet_version_;
bool draw_grid_;
bool m_inverted_lightness = false;
bool use_border_;
bool draw_guides_;
QList<Diagram::Guide> m_guides_list;
@@ -222,6 +223,7 @@ class Diagram : public QGraphicsScene
ExportProperties applyProperties(const ExportProperties &);
void setDisplayGrid(bool);
bool displayGrid();
void setInvertedLightness(bool);
void setDisplayGuides(bool);
bool displayGuides();
void updateProjectGuides(const QList<GuideProperties> &guides);
@@ -355,6 +357,18 @@ inline void Diagram::setDisplayGrid(bool dg) {
draw_grid_ = dg;
}
/**
@brief Diagram::setInvertedLightness
Tell the diagram whether the view painting it will show the result
with its lightness inverted (PaletteGraphicsView on a dark palette).
drawBackground draws a softer grid in that case. Printing
and export never set this.
@param inverted
*/
inline void Diagram::setInvertedLightness(bool inverted) {
m_inverted_lightness = inverted;
}
/**
@brief Diagram::displayGrid
@return draw_grid_ true if the grid is drawn, false otherwise.
+11 -3
View File
@@ -151,8 +151,8 @@ bool DiagramContext::operator!=(const DiagramContext &dc) const
void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const
{
foreach (QString key, keys()) {
if ((tag_name == "elementInformation") &&
(m_content[key].toString().trimmed().isEmpty())) {
const QString raw = m_content[key].toString();
if ((tag_name == "elementInformation") && raw.trimmed().isEmpty()) {
continue;
}
QDomElement property = e.ownerDocument().createElement(tag_name);
@@ -161,7 +161,15 @@ void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const
property.removeAttribute("name");
property.setAttribute("show", m_content_show[key]);
property.setAttribute("name", key);
QDomText value = e.ownerDocument().createTextNode(m_content[key].toString().trimmed());
// Trim stray leading/trailing whitespace around real content, but
// not a value that IS whitespace: unconditionally trimming an
// all-whitespace string collapses it to "", which is silently
// indistinguishable from a value that was never set. A title-block
// custom variable set to a single space -- a workaround for #973,
// where an unset variable renders as its own literal placeholder --
// would otherwise vanish on the very next save.
const QString stored = raw.trimmed().isEmpty() ? raw : raw.trimmed();
QDomText value = e.ownerDocument().createTextNode(stored);
property.appendChild(value);
e.appendChild(property);
}
+1 -1
View File
@@ -17,7 +17,7 @@
*/
#ifndef DIAGRAM_CONTEXT_H
#define DIAGRAM_CONTEXT_H
#include "pugixml/src/pugixml.hpp"
#include "pugixml.hpp"
#include <QDomElement>
#include <QHash>
+21 -7
View File
@@ -67,7 +67,19 @@ m_preview_item(nullptr)
dummy_diagram->setDisplayGrid(false);
dummy_diagram->fromXml(diagram_node, QPointF(0, 0), false, nullptr);
// Compute bounding rect of TOP-LEVEL items only (matching fromXml's added_items logic)
// Child items (DynamicElementTextItem, Terminal) are NOT included - they move with parents
QRectF top_level_rect;
for (auto *item : dummy_diagram->items()) {
if (!item->parentItem()) {
top_level_rect = top_level_rect.united(
item->mapToScene(item->boundingRect()).boundingRect());
}
}
m_items_top_left = top_level_rect.topLeft();
QRectF scene_rect = dummy_diagram->itemsBoundingRect();
if (!scene_rect.isEmpty()) {
QPixmap pixmap(scene_rect.toAlignedRect().size());
pixmap.fill(Qt::transparent);
@@ -80,10 +92,11 @@ m_preview_item(nullptr)
}
}
if (m_preview_item) {
m_preview_item->setPos(Diagram::snapToGrid(pos));
m_preview_item->setOpacity(0.6);
m_diagram->addItem(m_preview_item);
if (m_preview_item) {
QPointF snapped = Diagram::snapToGrid(pos);
m_preview_item->setPos(snapped);
m_preview_item->setOpacity(0.6);
m_diagram->addItem(m_preview_item);
m_running = true;
}
@@ -117,6 +130,7 @@ void DiagramEventAddMacro::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
if (m_preview_item) {
const auto pos_{Diagram::snapToGrid(event->scenePos())};
m_preview_item->setPos(pos_);
if (m_status_bar) {
@@ -141,7 +155,8 @@ void DiagramEventAddMacro::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
emit finish();
}
else if (event->button() == Qt::LeftButton) {
addMacro(Diagram::snapToGrid(event->scenePos()));
QPointF snapped = Diagram::snapToGrid(event->scenePos());
addMacro(snapped);
}
}
event->setAccepted(true);
@@ -238,10 +253,9 @@ void DiagramEventAddMacro::addMacro(QPointF final_pos)
if (!diagram_node.isNull()) {
QDomElement cloned_node = diagram_node.cloneNode(true).toElement();
DiagramContent pasted_content;
m_diagram->fromXml(cloned_node, final_pos, false, &pasted_content);
m_diagram->fromXml(cloned_node, final_pos + m_items_top_left, false, &pasted_content);
m_diagram->refreshContents();
// Prevent PasteDiagramCommand from erasing labels (BMK)
@@ -40,6 +40,7 @@ private:
QDomDocument m_macro_doc;
QGraphicsPixmapItem *m_preview_item;
QPointer<QStatusBar> m_status_bar;
QPointF m_items_top_left; // top-left of bounding rect of top-level items in the macro (for correct placement offset)
};
#endif // DIAGRAMEVENTADDMACRO_H
+32 -15
View File
@@ -22,6 +22,7 @@
#include "../qetapp.h"
#include "../qetdiagrameditor.h"
#include "../qetgraphicsitem/conductor.h"
#include "../qetproject.h"
#include <QSettings>
@@ -40,6 +41,7 @@
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.
@@ -51,8 +53,25 @@
QDomDocument document_xml;
if (!document_xml.setContent(clipboard_text)) return;
//Batch the database work the same way project loading does
//(QETProject::readProjectXml): without this, every addItem()
//below emits dataBaseUpdated(), which makes each connected
//table model re-run its full SQL query -- ~77 queries for a
//typical paste, i.e. the multi-second stall on Ctrl+V.
auto *db = m_diagram->project() ? m_diagram->project()->dataBase() : nullptr;
if (db) {
db->blockSignals(true);
db->setUpdateBlocked(true);
}
//Load items at their original XML coordinates.
m_diagram->fromXml(document_xml, QPointF(), false, &m_content);
if (db) {
db->blockSignals(false);
db->setUpdateBlocked(false);
db->updateDB();
}
if (!m_content.count()) return;
const QList<QGraphicsItem *> movable = m_content.items(MovableItems);
@@ -85,33 +104,23 @@
};
const QPointF grid_origin = snapGrid(top_left);
//Move the group to the cursor, rather than the cursor to the
//group. Both put the copy under the pointer, but warping the
//pointer also drags it back to the original's position, so the
//copy appears exactly on top of what was copied until the mouse
//is moved -- which is the thing pasting under the cursor was
//meant to avoid (issue #913). Taking the pointer away from
//where the user put it is also its own surprise.
m_group_origin = snapGrid(start_pos);
const QPointF offset = m_group_origin - grid_origin;
//Store each item's position after the move. moveTo() applies a
//Store each item's original 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) {
item->setPos(item->pos() + offset);
m_relative_pos.insert(item, item->pos());
}
m_group_origin = grid_origin;
//The conductors were laid out against the old terminal
//The conductors were laid out against the original terminal
//positions, so re-route them before anything is drawn.
const QList<Conductor *> conductors = m_content.conductors(DiagramContent::AnyConductor);
for (auto *conductor : conductors) {
conductor->updatePath();
}
//The baseline is known now, so moveTo() does not have to
//capture one from the first mouse movement.
//The baseline is the group's grid-snapped origin, so moveTo()
//does not have to capture one from the first mouse movement.
m_initial_cursor = m_group_origin;
m_baseline_captured = true;
@@ -125,6 +134,14 @@
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();
+90 -2
View File
@@ -39,7 +39,9 @@
#include "ElementsCollection/xmlelementcollection.h"
#include "NameList/nameslist.h"
#include "elementdialog.h"
#include <QApplication>
#include <QDropEvent>
#include <QPainter>
#include <QPointer>
/**
@@ -48,7 +50,7 @@
@param parent Le QWidget parent de cette vue de schema
*/
DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
QGraphicsView (parent),
PaletteGraphicsView (parent),
m_diagram (diagram)
{
grabGesture(Qt::PinchGesture);
@@ -443,6 +445,82 @@ void DiagramView::pasteHere()
paste(mapToScene(m_paste_here_pos));
}
/**
@brief DiagramView::duplicate
Copy the current selection and place the copy at @p stepOffset grid
steps from it, landing immediately rather than following the cursor
like Ctrl+V does (bugtracker #991). @p stepOffset comes from
DuplicateOffsetDialog: (1, 0) is one grid step right, (0, -1) is one
grid step up, and so on -- QET's own scene axes, X right and Y down.
No interactive placement step on purpose: the point of a duplicate
shortcut is unattended, repeatable stamping (configure the offset
once, then tap Ctrl+D to lay out a row), which following the cursor
would interrupt on every press. QET already reselects whatever a
paste just added (see PasteDiagramCommand::redo()), so the next
Ctrl+D naturally continues from the copy just placed, not the
original -- a press-and-hold row falls out of that for free, with no
special-casing needed here for "keep going from the last one".
The offset is applied by hand rather than by asking paste()/
Diagram::fromXml() to place the copy at a target position. Both of
those feed the position through Diagram::snapToGrid(), which reads
QApplication::keyboardModifiers() and rounds to the nearest PIXEL
instead of the grid whenever Ctrl is held -- and Ctrl is always held
here, this action's own shortcut being Ctrl+D. Measured the hard way
before settling on this: routing the offset through paste() first
produced copies off-grid on both axes, by an amount that tracked the
selection's own bounding-box geometry rather than being a fixed
error. fromXml() is instead called with no position at all, which
leaves every item at its source coordinates (landing the copy
exactly on top of the originals -- (0, 0) is not a position, this is
"keep the source coordinates"), and the offset is added directly
with setPos(). A plain addition cannot be off by a rounding rule
that never runs.
Conductors are not in the translated set: fromXml() itself does not
reposition them either -- they are loaded from XML after elements
are already in their final place and take their geometry from their
terminals, which have already moved with the elements that own
them. Likewise dynamic element texts are not translated separately:
they are children of their element and move with it under Qt's
normal parent-child transform.
*/
void DiagramView::duplicate(const QPoint &stepOffset)
{
if (!isInteractive() || m_diagram->isReadOnly()) return;
const QList<QGraphicsItem *> selection = m_diagram->selectedItems();
if (selection.isEmpty()) return;
QSettings settings;
const int x_grid = settings.value(QStringLiteral("diagrameditor/Xgrid"),
Diagram::xGrid).toInt();
const int y_grid = settings.value(QStringLiteral("diagrameditor/Ygrid"),
Diagram::yGrid).toInt();
const QPointF offset(stepOffset.x() * x_grid, stepOffset.y() * y_grid);
// Mirrors copy(), but does not touch the system clipboard: Ctrl+D
// should not clobber whatever the user last copied with Ctrl+C.
QDomDocument document = m_diagram->toXml(false, true);
DiagramContent pasted;
// No position argument -- see the function comment above for why
// the offset is not passed here.
m_diagram->fromXml(document, QPointF(), false, &pasted);
if (!pasted.count()) return;
const int movable = DiagramContent::Elements | DiagramContent::TextFields
| DiagramContent::Images | DiagramContent::Shapes
| DiagramContent::Tables | DiagramContent::TerminalStrip;
for (QGraphicsItem *item : pasted.items(movable))
item->setPos(item->pos() + offset);
m_diagram->clearSelection();
m_diagram->undoStack().push(new PasteDiagramCommand(m_diagram, pasted));
adjustSceneRect();
}
/**
Manage the events press click :
* click to add an independent text field
@@ -1081,6 +1159,16 @@ bool DiagramView::event(QEvent *e) {
return(QGraphicsView::event(e));
}
/**
@brief DiagramView::paintingInverted
Reimplemented from PaletteGraphicsView: tell the diagram it is being
drawn for an inverted display, so it softens its grid.
*/
void DiagramView::paintingInverted(bool inverted)
{
m_diagram->setInvertedLightness(inverted);
}
/**
@brief DiagramView::paintEvent
Reimplemented from QGraphicsView
@@ -1088,7 +1176,7 @@ bool DiagramView::event(QEvent *e) {
*/
void DiagramView::paintEvent(QPaintEvent *event)
{
QGraphicsView::paintEvent(event);
PaletteGraphicsView::paintEvent(event);
if (m_free_rubberbanding && m_free_rubberband.count() >= 3)
{
+4 -2
View File
@@ -22,7 +22,7 @@
#include "titleblock/templatelocation.h"
#include <QClipboard>
#include <QGraphicsView>
#include "palettegraphicsview.h"
class Conductor;
class Diagram;
@@ -35,7 +35,7 @@ class QGestureEvent;
This class provides a widget to render an electric diagram in an editable,
interactive way.
*/
class DiagramView : public QGraphicsView
class DiagramView : public PaletteGraphicsView
{
Q_OBJECT
@@ -84,6 +84,7 @@ class DiagramView : public QGraphicsView
///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;
void paintingInverted(bool inverted) override;
void mousePressEvent(QMouseEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
void mouseReleaseEvent(QMouseEvent *) override;
@@ -136,6 +137,7 @@ class DiagramView : public QGraphicsView
void copy();
void paste(const QPointF & = QPointF(), QClipboard::Mode = QClipboard::Clipboard);
void pasteHere();
void duplicate(const QPoint &stepOffset);
void adjustSceneRect();
void updateWindowTitle();
void resetConductors();
+11 -21
View File
@@ -19,6 +19,7 @@
#include "QPropertyUndoCommand/qpropertyundocommand.h"
#include "diagram.h"
#include "qetapp.h"
#include "qetgraphicsitem/dynamicelementtextitem.h"
#include "qetgraphicsitem/elementtextitemgroup.h"
@@ -137,25 +138,14 @@ void ElementTextsMover::endMovement()
QString ElementTextsMover::undoText() const
{
QString undo_text;
if(m_text_count == 1)
undo_text.append(QObject::tr("Déplacer un texte d'élément"));
else if(m_text_count > 1)
undo_text.append(QObject::tr("Déplacer %1 textes d'élément").arg(m_items_hash.size()));
if(m_group_count >= 1)
{
if(undo_text.isEmpty())
undo_text.append(QObject::tr("Déplacer"));
else
undo_text.append(QObject::tr(" et"));
if(m_group_count == 1)
undo_text.append(QObject::tr(" un groupe de texte"));
else
undo_text.append(QObject::tr((" %1 groupes de textes")).arg(m_group_count));
}
return undo_text;
QStringList parts;
if (m_text_count)
parts << QObject::tr("%n texte(s) d'élément", "", m_text_count);
if (m_group_count)
parts << QObject::tr("%n groupe(s) de textes", "", m_group_count);
if (parts.isEmpty())
return QString(); // should never occur
return QObject::tr("Déplacer %1").arg(QLocale(QETApp::interfaceLanguage()).createSeparatedList(parts));
}
+7 -1
View File
@@ -32,8 +32,14 @@ class QetGraphicsTableFactory
static void createAndAddNomenclature(Diagram *diagram);
static void createAndAddSummary(Diagram *diagram);
private:
// Public so a caller that has already built and configured an
// AddTableDialog itself (never shown or exec'd -- the two
// methods above always exec() one, which the scripting API
// cannot use headlessly) can create a table from it directly.
// create() only reads settings already on the dialog; nothing
// about it depends on the dialog having been shown.
static void create(Diagram *diagram, AddTableDialog *dialog);
private:
static QetGraphicsTableItem *newTable(
Diagram *diagram,
AddTableDialog *dialog,
+30
View File
@@ -72,6 +72,15 @@ bool AddTableDialog::adjustTableToFolio() const
return ui->m_adjust_table_size_cb->isChecked();
}
/**
@brief AddTableDialog::setAdjustTableToFolio
@param set
*/
void AddTableDialog::setAdjustTableToFolio(bool set)
{
ui->m_adjust_table_size_cb->setChecked(set);
}
/**
@brief AddTableDialog::addNewTableToNewDiagram
@return
@@ -81,6 +90,15 @@ bool AddTableDialog::addNewTableToNewDiagram() const
return ui->m_add_table_and_folio->isChecked();
}
/**
@brief AddTableDialog::setAddNewTableToNewDiagram
@param set
*/
void AddTableDialog::setAddNewTableToNewDiagram(bool set)
{
ui->m_add_table_and_folio->setChecked(set);
}
/**
@brief AddTableDialog::tableName
@return
@@ -90,6 +108,18 @@ QString AddTableDialog::tableName() const
return ui->m_table_name_le->text();
}
/**
@brief AddTableDialog::setTableName
Set the name field directly, so a caller that builds this dialog to
read from (never shows or execs it -- the scripting API's addTable())
does not need a name typed by a user who was never there to type one.
@param name
*/
void AddTableDialog::setTableName(const QString &name)
{
ui->m_table_name_le->setText(name);
}
/**
@brief AddTableDialog::headerMargins
@return
+3
View File
@@ -44,9 +44,12 @@ class AddTableDialog : public QDialog
void setQueryWidget(QWidget *widget);
bool adjustTableToFolio() const;
void setAdjustTableToFolio(bool set);
bool addNewTableToNewDiagram() const;
void setAddNewTableToNewDiagram(bool set);
QString tableName() const;
void setTableName(const QString &name);
QMargins headerMargins() const;
Qt::Alignment headerAlignment() const;
+237
View File
@@ -0,0 +1,237 @@
/*
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 "palettegraphicsview.h"
#include "qetpalette.h"
#include <QApplication>
#include <QEvent>
#include <QPaintEvent>
#include <QStyleHintReturnMask>
#include <QStyleOptionRubberBand>
#include <QtMath>
namespace {
/**
QGraphicsScene::drawItems() is protected, and QGraphicsView::drawItems()
hands the scene the viewport only when the painter is on it. The
view paints into an image, and still needs the scene to get the
viewport: that is what makes the scene record where it painted
each item, which is where the item is erased from when it moves.
Naming the member through a derived class is the standard way to
a pointer to a protected member; a call through it dispatches to
the scene's own override, if any.
*/
struct SceneAccess : QGraphicsScene
{
using DrawItems = void (QGraphicsScene::*)(QPainter *, int, QGraphicsItem *[],
const QStyleOptionGraphicsItem[], QWidget *);
static DrawItems drawItemsPointer() { return &SceneAccess::drawItems; }
};
}
PaletteGraphicsView::PaletteGraphicsView(QWidget *parent) :
QGraphicsView(parent)
{
qApp->installEventFilter(this);
}
PaletteGraphicsView::PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent) :
QGraphicsView(scene, parent)
{
qApp->installEventFilter(this);
}
/**
@brief PaletteGraphicsView::invertsLightness
@return true when the scene is shown with its lightness inverted,
i.e. when the application palette is dark. The application palette,
not the view's own: a style sheet on an ancestor (the folio tab
widget has one) makes QStyleSheetStyle pin the palette of every
widget under it to the application palette in force when the sheet
was applied, so after a live light/dark switch palette() is stale.
*/
bool PaletteGraphicsView::invertsLightness() const
{
return QET::Palette::isDark(QApplication::palette());
}
void PaletteGraphicsView::paintingInverted(bool inverted)
{
Q_UNUSED(inverted)
}
/**
@brief PaletteGraphicsView::eventFilter
Repaint the whole viewport when the application palette changes. Qt
sends that change to the application object and then repaints only
the widgets whose own palette changed with it, which under a style
sheet is not the case (see invertsLightness()): the scene would then
repaint only what it updates itself, and the viewport around the
sheet would keep the colors of the previous palette. The filter sits
on the application object, the one receiver Qt always notifies.
*/
bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event)
{
if (watched == qApp && event->type() == QEvent::ApplicationPaletteChange)
viewport()->update();
return QGraphicsView::eventFilter(watched, event);
}
/**
@brief PaletteGraphicsView::paintEvent
Paints as QGraphicsView on a light palette, inverted on a dark one.
*/
void PaletteGraphicsView::paintEvent(QPaintEvent *event)
{
if (invertsLightness() && !customBackgroundColor())
{
paintInverted(event);
return;
}
m_buffer = QImage();
QGraphicsView::paintEvent(event);
}
/**
@brief PaletteGraphicsView::paintInverted
Run QGraphicsView::paintEvent() with the drawing hooks redirected to
an off-screen image of the viewport, then invert the lightness of the
exposed part of that image between the palette's Base and Text colors
and blit it to the viewport. Inverting the finished rendering turns
the white sheet dark and the black ink light in one pass, and keeps
the hue of colored strokes. The image is in viewport coordinates, so
the hooks paint with the view's own transform and the scene records
the items' places in the viewport, as it does on a light palette.
@param event the paint event, for the exposed area
*/
void PaletteGraphicsView::paintInverted(QPaintEvent *event)
{
const QRect exposed = event->rect().intersected(viewport()->rect());
if (exposed.isEmpty())
return;
const qreal ratio = viewport()->devicePixelRatioF();
const QSize size(qCeil(viewport()->width() * ratio), qCeil(viewport()->height() * ratio));
if (m_buffer.size() != size || m_buffer.devicePixelRatio() != ratio)
{
m_buffer = QImage(size, QImage::Format_RGB32);
m_buffer.setDevicePixelRatio(ratio);
}
m_buffer_painter.begin(&m_buffer);
// The hooks paint only what the scene draws; what they leave blank is
// the white sheet, which the inversion turns into the Base color.
m_buffer_painter.fillRect(exposed, Qt::white);
m_buffer_painter.setClipRect(exposed);
m_buffer_painter.setRenderHints(renderHints());
m_buffer_painter.setWorldTransform(viewportTransform());
m_inverting = true;
paintingInverted(true);
const OptimizationFlags flags = optimizationFlags();
setOptimizationFlag(QGraphicsView::IndirectPainting, true);
QGraphicsView::paintEvent(event);
setOptimizationFlags(flags);
paintingInverted(false);
m_inverting = false;
m_buffer_painter.end();
blitInverted(exposed);
}
/**
@brief PaletteGraphicsView::drawBackground
Into the off-screen image while painting inverted, else as
QGraphicsView.
*/
void PaletteGraphicsView::drawBackground(QPainter *painter, const QRectF &rect)
{
QGraphicsView::drawBackground(m_inverting ? &m_buffer_painter : painter, rect);
}
/**
@brief PaletteGraphicsView::drawItems
Into the off-screen image while painting inverted, with the viewport
as the scene's widget (see SceneAccess), else as QGraphicsView.
*/
void PaletteGraphicsView::drawItems(QPainter *painter, int count, QGraphicsItem *items[],
const QStyleOptionGraphicsItem options[])
{
if (m_inverting && scene())
(scene()->*SceneAccess::drawItemsPointer())(&m_buffer_painter, count, items, options, viewport());
else
QGraphicsView::drawItems(painter, count, items, options);
}
/**
@brief PaletteGraphicsView::drawForeground
Into the off-screen image while painting inverted, else as
QGraphicsView.
*/
void PaletteGraphicsView::drawForeground(QPainter *painter, const QRectF &rect)
{
QGraphicsView::drawForeground(m_inverting ? &m_buffer_painter : painter, rect);
}
/**
@brief PaletteGraphicsView::blitInverted
Invert the lightness of \a area of the off-screen image and draw it on
the viewport, then the selection rubber band on top: the one
QGraphicsView::paintEvent() drew went under the blit.
@param area the part of the viewport to blit, in viewport coordinates
*/
void PaletteGraphicsView::blitInverted(const QRect &area)
{
const qreal ratio = m_buffer.devicePixelRatio();
QImage part = m_buffer.copy(QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect());
part.setDevicePixelRatio(ratio);
// The application palette, for the reason given in invertsLightness().
const QPalette application_palette = QApplication::palette();
QET::Palette::invertLightness(part, application_palette.color(QPalette::Base),
application_palette.color(QPalette::Text));
QPainter painter(viewport());
painter.drawImage(area.topLeft(), part);
drawRubberBand(painter);
}
/**
@brief PaletteGraphicsView::drawRubberBand
Draw the selection rubber band the way QGraphicsView::paintEvent does,
after the inversion, in the palette colors.
@param painter a painter on the viewport
*/
void PaletteGraphicsView::drawRubberBand(QPainter &painter)
{
const QRect band = rubberBandRect();
if (band.isNull())
return;
QStyleOptionRubberBand option;
option.initFrom(viewport());
option.rect = band;
option.shape = QRubberBand::Rectangle;
QStyleHintReturnMask mask;
if (viewport()->style()->styleHint(QStyle::SH_RubberBand_Mask, &option,
viewport(), &mask))
painter.setClipRegion(mask.region, Qt::IntersectClip);
viewport()->style()->drawControl(QStyle::CE_RubberBand, &option,
&painter, viewport());
}
+99
View File
@@ -0,0 +1,99 @@
/*
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 PALETTE_GRAPHICS_VIEW_H
#define PALETTE_GRAPHICS_VIEW_H
#include <QGraphicsView>
#include <QImage>
#include <QPainter>
/**
A QGraphicsView that shows its scene with inverted lightness while the
application palette is dark: white becomes the palette's Base color,
black its Text color, and colored strokes keep their hue. The scene
itself is left as drawn, so printing and exporting it still give black
on white. On a light palette the view paints exactly as QGraphicsView
does.
On a dark palette the view still runs QGraphicsView::paintEvent(), with
the IndirectPainting flag set for the duration of that call, so that
the background, the items and the foreground come through the
drawBackground(), drawItems() and drawForeground() hooks. (That flag
selects Qt's older item-painting algorithm, which first builds a list
of the exposed items and their style options; it is set only while
the view paints inverted.) The hooks
paint into an off-screen image the size of the viewport, in viewport
coordinates; paintEvent() then inverts the lightness of the exposed
part of that image and blits it. Going through the real paint event,
and handing the scene the viewport when the items are drawn, keeps the
view on QGraphicsView's default update path, which erases a moved item
where it was last painted, children included, even a child whose
geometry is set while its parent is painted (a terminal's help lines).
The alternative, rendering with QGraphicsView::render(), needed a
receiver on QGraphicsScene::changed() to keep the scene's updates
flowing, and that receiver puts the scene on its Qt 4.4 compatibility
path, which erases only the moved item's own old rectangle: a moved
element then left its terminals' help lines, which span the whole
sheet, behind at every step (#954).
The CacheBackground cache mode is not supported on the inverted path.
*/
class PaletteGraphicsView : public QGraphicsView
{
Q_OBJECT
public:
explicit PaletteGraphicsView(QWidget *parent = nullptr);
explicit PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent = nullptr);
bool invertsLightness() const;
static void setCustomBackgroundColor(bool custom) { s_custom_bg = custom; }
static bool customBackgroundColor() { return s_custom_bg; }
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void drawBackground(QPainter *painter, const QRectF &rect) override;
void drawItems(QPainter *painter, int count, QGraphicsItem *items[],
const QStyleOptionGraphicsItem options[]) override;
void drawForeground(QPainter *painter, const QRectF &rect) override;
/**
Called with true right before the scene is painted for an
inverted display and with false right after, so a scene can
adapt what it draws (a softer grid, for instance). Does
nothing by default.
*/
virtual void paintingInverted(bool inverted);
private:
void paintInverted(QPaintEvent *event);
void blitInverted(const QRect &area);
void drawRubberBand(QPainter &painter);
/// The off-screen image the hooks paint into while m_inverting:
/// the viewport's size, in its coordinates. Kept between paints,
/// dropped when the view paints on a light palette again.
QImage m_buffer;
QPainter m_buffer_painter;
/// True while paintEvent() paints for an inverted display.
bool m_inverting = false;
static inline bool s_custom_bg = false;
};
#endif
+55 -50
View File
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "qet.h"
#include "qetapp.h"
#include "qeticons.h"
#include "shortcutmanager.h"
@@ -271,84 +272,88 @@ QString QET::ElementsAndConductorsSentence(
int tables_count,
int terminal_strip_count)
{
QString text;
QStringList parts;
if (elements_count) {
text += QObject::tr(
"%n élément(s)",
"part of a sentence listing the content of a diagram",
elements_count
parts.append(
QObject::tr(
"%n élément(s)",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
elements_count
)
);
}
if (conductors_count) {
if (!text.isEmpty()) text += ", ";
text += QObject::tr(
"%n conducteur(s)",
"part of a sentence listing the content of a diagram",
conductors_count
parts.append(
QObject::tr(
"%n conducteur(s)",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
conductors_count
)
);
}
if (texts_count) {
if (!text.isEmpty()) text += ", ";
text += QObject::tr(
"%n champ(s) de texte",
"part of a sentence listing the content of a diagram",
texts_count
parts.append(
QObject::tr(
"%n champ(s) de texte",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
texts_count
)
);
}
if (images_count) {
if (!text.isEmpty()) text += ", ";
// Qt's %n only selects a grammatical singular/plural form (the
// "(s)" convention used by every other count here) -- it never
// spells the number out as a word, so getting "une image"
// instead of the literal "1 image" for the single-item case
// means handling that count outside %n entirely, with its own
// fixed string.
text += images_count == 1
? QObject::tr("une image", "part of a sentence listing the content of a diagram")
: QObject::tr(
"%n images",
"part of a sentence listing the content of a diagram",
images_count
);
parts.append(
QObject::tr(
"%n image(s)",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
images_count
)
);
}
if (shapes_count) {
if (!text.isEmpty()) text += ", ";
text += QObject::tr(
"%n forme(s)",
"part of a sentence listing the content of a diagram",
shapes_count
parts.append(
QObject::tr(
"%n forme(s)",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
shapes_count
)
);
}
if (element_text_count) {
if (!text.isEmpty()) text += ", ";
text += QObject::tr(
"%n texte(s) d'élément",
"part of a sentence listing the content of a diagram",
element_text_count);
parts.append(
QObject::tr(
"%n texte(s) d'élément",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
element_text_count
)
);
}
if (tables_count) {
if (!text.isEmpty()) text += ", ";
text += QObject::tr(
"%n tableau(s)",
"part of a sentence listing the content of diagram",
tables_count);
parts.append(
QObject::tr(
"%n tableau(s)",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
tables_count
)
);
}
if (terminal_strip_count) {
if (!text.isEmpty()) text += ", ";
text += QObject::tr(
"%n plan de bornes",
"part of a sentence listing the content of a diagram",
terminal_strip_count);
parts.append(
QObject::tr(
"%n plan(s) de bornes",
"Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.",
terminal_strip_count
)
);
}
return(text);
return QLocale(QETApp::interfaceLanguage()).createSeparatedList(parts);
}
/**
+90 -1
View File
@@ -235,6 +235,8 @@ QString QETApp::loadedQtTranslationFile()
*/
void QETApp::setLanguage(const QString &desired_language) {
QString languages_path = languagesPath();
m_interface_language = desired_language;
// load Qt library translations
QString qt_l10n_path = QLibraryInfo::path(QLibraryInfo::TranslationsPath);
@@ -1808,6 +1810,85 @@ void QETApp::useSystemPalette(bool use) {
file.close();
}
}
// Widgets with their own style sheet keep the palette they were
// polished with; after a live light/dark switch they would stay in
// the old colors (see QET::Palette::refreshStyleSheets).
QET::Palette::refreshStyleSheets();
}
/**
@brief QETApp::useCustomPalette
Apply a user-chosen color as the application-wide palette.
Builds a full QPalette from \a color, keeping the system palette
as a fallback for roles we don't touch.
@param color the user-chosen base color
*/
void QETApp::useCustomPalette(const QColor &color) {
if (!color.isValid())
return;
// Derive readable text colors from the chosen color.
const bool dark = color.lightness() < 128;
const QColor text = dark ? QColor(220, 220, 220) : QColor(30, 30, 30);
const QColor disabled_text = dark ? QColor(175, 175, 175) : QColor(128, 128, 128);
// Slightly lighter/darker for button and window shading.
QColor button = color;
button = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() + 0.08, 1.0)
: qMax(color.lightnessF() - 0.08, 0.0));
QColor light = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() + 0.15, 1.0)
: qMax(color.lightnessF() - 0.15, 0.0));
QColor mid = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() + 0.04, 1.0)
: qMax(color.lightnessF() - 0.04, 0.0));
QColor dark_c = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() - 0.04, 1.0)
: qMax(color.lightnessF() - 0.12, 0.0));
QColor shadow = QColor::fromHslF(color.hslHueF(),
color.hslSaturationF(),
dark ? qMin(color.lightnessF() - 0.10, 1.0)
: qMax(color.lightnessF() - 0.20, 0.0));
QPalette p;
// Active and Inactive get the same colors; only Disabled differs.
for (auto group : {QPalette::Active, QPalette::Inactive}) {
p.setColor(group, QPalette::Window, color);
p.setColor(group, QPalette::WindowText, text);
p.setColor(group, QPalette::Base, color);
p.setColor(group, QPalette::AlternateBase, button);
p.setColor(group, QPalette::Text, text);
p.setColor(group, QPalette::Button, button);
p.setColor(group, QPalette::ButtonText, text);
p.setColor(group, QPalette::BrightText, dark ? QColor(255,90,90) : Qt::white);
p.setColor(group, QPalette::Highlight, QColor(30, 96, 176));
p.setColor(group, QPalette::HighlightedText, Qt::white);
p.setColor(group, QPalette::ToolTipBase, button);
p.setColor(group, QPalette::ToolTipText, text);
p.setColor(group, QPalette::Light, light);
p.setColor(group, QPalette::Midlight, mid);
p.setColor(group, QPalette::Mid, mid);
p.setColor(group, QPalette::Dark, dark_c);
p.setColor(group, QPalette::Shadow, shadow);
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)
p.setColor(group, QPalette::Accent, QColor(30, 96, 176));
#endif
}
p.setColor(QPalette::Disabled, QPalette::WindowText, disabled_text);
p.setColor(QPalette::Disabled, QPalette::Text, disabled_text);
p.setColor(QPalette::Disabled, QPalette::ButtonText, disabled_text);
qApp->setPalette(p);
qApp->setStyleSheet(QString());
// Switch icon theme to match light/dark.
applyIconTheme(p);
QET::Palette::refreshStyleSheets();
}
/**
@@ -2398,7 +2479,13 @@ void QETApp::initStyle()
//Apply or not the system style
QSettings settings;
useSystemPalette(settings.value("usesystemcolors", true).toBool());
if (settings.value("usesystemcolors", true).toBool()) {
useSystemPalette(true);
} else if (settings.contains("customapplicationcolor")) {
useCustomPalette(QColor(settings.value("customapplicationcolor").toString()));
} else {
useSystemPalette(false);
}
#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
// Setting an application palette stops Qt from following the OS
@@ -3039,3 +3126,5 @@ int QETApp::projectId(const QETProject *project) {
}
return(-1);
}
QString QETApp::m_interface_language;
+4
View File
@@ -68,6 +68,7 @@ class QETApp : public QObject
public:
static QETApp *instance();
void setLanguage(const QString &);
static QString interfaceLanguage() { return m_interface_language; }
static QString langFromSetting ();
void switchLayout(Qt::LayoutDirection);
static void printHelp();
@@ -246,6 +247,8 @@ class QETApp : public QObject
static QString m_user_company_tbt_dir;
static QString m_user_custom_tbt_dir;
static QString m_user_macros_dir;
static QString m_interface_language;
public slots:
void systray(QSystemTrayIcon::ActivationReason);
@@ -263,6 +266,7 @@ class QETApp : public QObject
void setMainWindowVisible(QMainWindow *, bool);
void invertMainWindowVisibility(QWidget *);
void useSystemPalette(bool);
void useCustomPalette(const QColor &color);
void quitQET();
void checkRemainingWindows();
void openFiles(const QETArguments &);
+76 -12
View File
@@ -51,11 +51,14 @@
#include "shortcutmanager.h"
#include "ui/bomexportdialog.h"
#include "ui/conductorcolortoolbutton.h"
#include "ui/diagrambgcolorbutton.h"
#include "ui/duplicateoffsetdialog.h"
#include "ui/jumptoelementdialog.h"
#include "ui/diagrampropertieseditordockwidget.h"
#include "ui/backupdialog.h"
#include "ui/dialogwaiting.h"
#include "undocommand/addelementtextcommand.h"
#include "utils/qetsettings.h"
#include "utils/qetutils.h"
#include "undocommand/rotateselectioncommand.h"
#include "undocommand/rotatetextscommand.h"
@@ -373,6 +376,46 @@ void QETDiagramEditor::setUpActions()
new DiagramEventAddPaste(dv->diagram(), start_pos));
});
//Duplicate: copy the selection and place it at a configured,
//grid-step offset immediately -- no interactive follow-the-
//cursor step, unlike Ctrl+V above. That is deliberate (#991):
//the point of a duplicate shortcut is repeatable, unattended
//stamping (configure the offset once, then tap Ctrl+D to lay
//out a row), which an interactive placement would interrupt on
//every press.
m_duplicate = new QAction(QET::Icons::EditCopy, tr("Dupli&quer"), this);
ShortcutManager::instance().registerAction(m_duplicate, "diagrameditor.duplicate", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_D);
m_duplicate->setStatusTip(tr("Copie la sélection, décalée de l'espacement configuré", "status bar tip"));
connect(m_duplicate, &QAction::triggered, [this]() {
auto *dv = currentDiagramView();
if (!dv || !dv->diagram()) return;
//Ask the first time only -- every later press reuses whatever
//was confirmed then, so the shortcut can be tapped repeatedly
//without an interruption each time. m_configure_duplicate
//below is the deliberate way back into this dialog.
if (!DuplicateOffsetDialog::hasSavedStepOffset()) {
DuplicateOffsetDialog dialog(this);
if (dialog.exec() != QDialog::Accepted) return;
DuplicateOffsetDialog::saveStepOffset(dialog.stepOffset());
}
dv->duplicate(DuplicateOffsetDialog::savedStepOffset());
});
//Reopens the dialog above on demand, to change the spacing or
//direction a later Ctrl+D should use. Enabled unconditionally
//(see slot_updateComplexActions()): it only ever writes a
//setting, so it does not need a diagram open or anything
//selected the way m_duplicate itself does.
m_configure_duplicate = new QAction(tr("Configurer la duplication..."), this);
m_configure_duplicate->setStatusTip(tr("Choisir l'espacement et la direction utilisés par Dupliquer", "status bar tip"));
connect(m_configure_duplicate, &QAction::triggered, [this]() {
DuplicateOffsetDialog dialog(this);
if (dialog.exec() == QDialog::Accepted) {
DuplicateOffsetDialog::saveStepOffset(dialog.stepOffset());
}
});
//Reset conductor path
m_conductor_reset = new QAction(QET::Icons::ConductorSettings, tr("Réinitialiser les conducteurs"), this);
ShortcutManager::instance().registerAction(m_conductor_reset, "diagrameditor.conductor_reset", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_K);
@@ -413,15 +456,8 @@ void QETDiagramEditor::setUpActions()
pv->project()->setAutoBreakConductor(abc);
});
//Switch background color
m_grey_background = new QAction (QET::Icons::DiagramBg, tr("Couleur de fond blanc/gris","Tool tip of white/grey background button"), this);
m_grey_background -> setStatusTip (tr("Affiche la couleur de fond du folio en blanc ou en gris", "Status tip of white/grey background button"));
m_grey_background -> setCheckable (true);
connect (m_grey_background, &QAction::triggered, [this](bool checked) {
Diagram::background_color = checked ? Qt::darkGray : Qt::white;
if (this->currentDiagramView() && this->currentDiagramView()->diagram())
this->currentDiagramView()->diagram()->update();
});
//Diagram background color picker
m_background_color_button = new DiagramBgColorToolButton(this, this);
//Draw or not the background grid
m_draw_grid = new QAction ( QET::Icons::Grid, tr("Afficher la grille"), this);
@@ -900,6 +936,7 @@ void QETDiagramEditor::setUpToolBar()
main_tool_bar -> addAction(m_cut);
main_tool_bar -> addAction(m_copy);
main_tool_bar -> addAction(m_paste);
main_tool_bar -> addAction(m_duplicate);
main_tool_bar -> addSeparator();
main_tool_bar -> addAction(m_delete_selection);
main_tool_bar -> addAction(m_rotate_selection);
@@ -912,7 +949,7 @@ void QETDiagramEditor::setUpToolBar()
view_tool_bar -> addSeparator();
view_tool_bar -> addAction(m_draw_grid);
view_tool_bar -> addAction(m_draw_guides);
view_tool_bar -> addAction (m_grey_background);
view_tool_bar -> addWidget(m_background_color_button);
view_tool_bar -> addSeparator();
view_tool_bar -> addActions(m_zoom_action_toolBar);
@@ -991,6 +1028,8 @@ void QETDiagramEditor::setUpMenu()
menu_edition -> addAction(m_cut);
menu_edition -> addAction(m_copy);
menu_edition -> addAction(m_paste);
menu_edition -> addAction(m_duplicate);
menu_edition -> addAction(m_configure_duplicate);
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
@@ -1063,7 +1102,7 @@ void QETDiagramEditor::setUpMenu()
menu_affichage -> addSeparator();
menu_affichage -> addAction(m_draw_grid);
menu_affichage -> addAction(m_draw_guides);
menu_affichage -> addAction(m_grey_background);
menu_affichage -> addMenu(m_background_color_button->menu());
menu_affichage -> addSeparator();
menu_affichage -> addActions(m_zoom_actions_group.actions());
@@ -1880,7 +1919,7 @@ void QETDiagramEditor::slot_updateActions()
m_select_actions_group. setEnabled(opened_diagram);
m_add_item_actions_group. setEnabled(editable_project);
m_row_column_actions_group. setEnabled(editable_project);
m_grey_background-> setEnabled(opened_diagram);
m_background_color_button-> setEnabled(opened_diagram);
m_draw_grid-> setEnabled(opened_diagram);
m_draw_guides-> setEnabled(opened_diagram);
@@ -1953,6 +1992,7 @@ void QETDiagramEditor::slot_updateComplexActions()
<< m_find_element
<< m_cut
<< m_copy
<< m_duplicate
<< m_delete_selection
<< m_rotate_selection
<< m_rotate_group_selection
@@ -1982,6 +2022,7 @@ void QETDiagramEditor::slot_updateComplexActions()
bool deletable_items = dc.hasDeletableItems();
m_cut -> setEnabled(!ro && copiable_items);
m_copy -> setEnabled(copiable_items);
m_duplicate -> setEnabled(!ro && copiable_items);
m_delete_selection -> setEnabled(!ro && deletable_items);
m_rotate_selection -> setEnabled(!ro && diagram_->canRotateSelection());
m_rotate_group_selection -> setEnabled(!ro && diagram_->canRotateSelection());
@@ -3128,6 +3169,29 @@ void QETDiagramEditor::slot_runScript() {
QETProject *project = currentProject();
if (!project) return;
// Scripting is off until somebody says otherwise, so the first use has
// to ask. Asking here rather than greying the action out keeps the
// feature discoverable: a disabled menu entry tells a user that
// something exists and nothing about how to have it.
if (!QetSettings::scriptingEnabled()) {
const QMessageBox::StandardButton answer = QET::QetMessageBox::question(
this,
tr("Exécuter un script"),
tr("Les scripts sont désactivés.\n\n"
"Un script s'exécute avec vos droits : il peut lire et "
"modifier le projet ouvert et écrire des fichiers. "
"N'exécutez que des scripts dont vous connaissez "
"l'origine.\n\n"
"Activer les scripts ? Ce réglage est modifiable dans "
"Configurer QElectroTech > Général > Projets."),
QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer != QMessageBox::Yes) {
return;
}
QetSettings::setScriptingEnabled(true);
}
const QString script_path = QFileDialog::getOpenFileName(
this,
tr("Exécuter un script"),
+5 -1
View File
@@ -34,6 +34,7 @@ class QETResult;
class ProjectView;
class ConductorColorToolButton;
class CustomElement;
class DiagramBgColorToolButton;
class Diagram;
class DiagramView;
class Element;
@@ -198,9 +199,10 @@ class QETDiagramEditor : public QETMainWindow
*undo, ///< Cancel the latest action
*redo, ///< Redo the latest cancelled operation
*m_paste, ///< Paste clipboard content on the current diagram
*m_duplicate, ///< Copy selection, offset by the configured step (#991)
*m_configure_duplicate, ///< Reopen the duplicate offset/direction dialog (#991)
*m_auto_conductor, ///< Enable/Disable the use of auto conductor
*m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor
*m_grey_background, ///< Switch the background color in white or grey
*m_draw_grid, ///< Switch the background grid display or not
*m_draw_guides = nullptr, ///< Switch the custom guides display or not
*m_project_edit_properties, ///< Edit the properties of the current project.
@@ -240,6 +242,8 @@ class QETDiagramEditor : public QETMainWindow
///< One-click conductor colour, in the "Schéma" toolbar
ConductorColorToolButton *m_conductor_color_button = nullptr;
///< Diagram background color picker, in the "Affichage" toolbar
DiagramBgColorToolButton *m_background_color_button = nullptr;
QList <QAction *> m_zoom_action_toolBar; ///Only zoom action must displayed in the toolbar
@@ -379,6 +379,21 @@ void ProjectDBModel::fillValue()
while (query_.next())
{
//This query text comes out of the project file, so its result is
//not bounded by anything the project actually contains: a
//recursive CTE produces rows for as long as anyone reads them.
//Without this, opening such a file hangs QElectroTech at 100% CPU
//while m_record grows until memory runs out. @see
//projectDataBase::MaxResultRows.
if (m_record.size() >= projectDataBase::MaxResultRows) {
qWarning().noquote()
<< "ProjectDBModel: query stopped after"
<< projectDataBase::MaxResultRows
<< "rows, which is far more than a folio table can show."
<< "The table is incomplete. Query:" << m_query;
break;
}
QStringList record_;
auto i=0;
while (query_.value(i).isValid())
+28
View File
@@ -1310,6 +1310,34 @@ const QList<ConductorSegment *> Conductor::segmentsList() const
return(segments_vector);
}
/**
@brief Conductor::moveSegment
Move one segment of this conductor by (dx, dy), the same primitive
handlerMouseMoveEvent()/handlerMouseReleaseEvent() apply on a manual
drag -- moveX()/moveY() each silently no-op on the wrong axis or a
static (terminal-anchored) segment, so both are always called and
whichever applies takes effect. Unlike a drag this commits the whole
move as a single undo step.
@param index a segmentsList() index
@param dx @param dy the movement, in the diagram's own coordinates
@return false if index is out of range
*/
bool Conductor::moveSegment(int index, qreal dx, qreal dy)
{
const QList<ConductorSegment *> segs = segmentsList();
if (index < 0 || index >= segs.count()) return false;
before_mov_text_pos_ = m_text_item->pos();
ConductorSegment *seg = segs.at(index);
seg->moveX(dx);
seg->moveY(dy);
modified_path = true;
segmentsToPath();
calculateTextItemPosition();
saveProfile();
return true;
}
/**
@brief Conductor::length
@return the length of this conductor
+1
View File
@@ -114,6 +114,7 @@ class Conductor : public QGraphicsObject
public:
QVector <QPointF> handlerPoints() const;
const QList<ConductorSegment *> segmentsList() const;
bool moveSegment(int index, qreal dx, qreal dy);
void setPropertyToPotential(
const ConductorProperties &property,
@@ -24,6 +24,7 @@
#include "../qetgraphicsitem/terminal.h"
#include "../qetinformation.h"
#include "../utils/qetutils.h"
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
#include "crossrefitem.h"
#include "element.h"
#include "elementtextitemgroup.h"
@@ -67,7 +68,9 @@ DynamicElementTextItem::DynamicElementTextItem(Element *parent_element) :
}
DynamicElementTextItem::~DynamicElementTextItem()
{}
{
removeResizeHandles();
}
/**
@brief DynamicElementTextItem::textFromMetaEnum
@@ -629,7 +632,13 @@ void DynamicElementTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
int diffx = qRound(current_parent_pos.x() - button_down_parent_pos.x());
int diffy = qRound(current_parent_pos.y() - button_down_parent_pos.y());
QPointF new_pos = m_initial_position + QPointF(diffx, diffy);
setPos(new_pos);
//Snap to the grid, Ctrl to place freely -- the same line
//ElementTextItemGroup::mouseMoveEvent() and
//ElementTextsMover::continueMovement() already use, and
//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));
if(diagram())
diagram()->elementTextsMover().continueMovement(event);
@@ -722,7 +731,20 @@ void DynamicElementTextItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event)
void DynamicElementTextItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
DiagramTextItem::paint(painter, option, widget);
//Only ever repositions already-existing sibling items here --
//never adds or removes one. paint() runs while QGraphicsScene is
//iterating its item list to draw it, and mutating that list mid
//-iteration (which addResizeHandles()/removeResizeHandles() do,
//through QGraphicsScene::addItem()/removeItem()) crashes. An
//earlier version of this fix called them from here and crashed
//qelectrotech reproducibly on deselecting a text (SIGABRT); see
//refreshResizeHandlesVisibility() for where that now happens
//instead -- itemChange(), Qt's own safe hook for exactly this,
//already used below for this item's own selection.
if (m_left_resize_handle || m_right_resize_handle)
updateResizeHandlesPos();
if (m_frame)
{
painter->save();
@@ -812,15 +834,41 @@ QVariant DynamicElementTextItem::itemChange(QGraphicsItem::GraphicsItemChange ch
updateXref();
updateXref();
}
else if (change == QGraphicsItem::ItemSelectedHasChanged)
{
refreshResizeHandlesVisibility();
}
else if (change == QGraphicsItem::ItemSceneHasChanged && !scene())
{
removeResizeHandles();
}
return QGraphicsObject::itemChange(change, value);
}
bool DynamicElementTextItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event)
{
if (watched == m_left_resize_handle || watched == m_right_resize_handle)
{
auto *handle = static_cast<QetGraphicsHandlerItem *>(watched);
if (event->type() == QEvent::GraphicsSceneMousePress) {
handlerMousePressEvent(handle, static_cast<QGraphicsSceneMouseEvent *>(event));
return true;
}
else if (event->type() == QEvent::GraphicsSceneMouseMove) {
handlerMouseMoveEvent(handle, static_cast<QGraphicsSceneMouseEvent *>(event));
return true;
}
else if (event->type() == QEvent::GraphicsSceneMouseRelease) {
handlerMouseReleaseEvent(handle, static_cast<QGraphicsSceneMouseEvent *>(event));
return true;
}
return false;
}
if(watched != m_slave_Xref_item)
return false;
if(event->type() == QEvent::GraphicsSceneHoverEnter) {
m_slave_Xref_item->setDefaultTextColor(Qt::blue);
return true;
@@ -833,10 +881,166 @@ bool DynamicElementTextItem::sceneEventFilter(QGraphicsItem *watched, QEvent *ev
zoomToLinkedElement();
return true;
}
return false;
}
/**
@brief DynamicElementTextItem::refreshResizeHandlesVisibility
Show the resize handles when this text is selected directly, OR when its
parent element is -- which is what an ordinary click without Shift
selects (DynamicElementTextItem::mousePressEvent() forwards a plain
click to the parent, so dragging a symbol by its label moves the whole
symbol; a pre-existing, unrelated behaviour, left untouched here).
Without this, the handles were reachable only via Shift+click or a
right-click's context menu, neither of which a user reaches for to
resize a text field (qelectrotech#591, reported by @arummler).
Called from itemChange() -- both this item's own ItemSelectedHasChanged,
below, and Element::itemChange() on the parent's, which calls this on
every one of its texts. Not from paint(): see the comment there for why
that crashed.
*/
void DynamicElementTextItem::refreshResizeHandlesVisibility()
{
const bool handles_wanted = isSelected() || (m_parent_element && m_parent_element->isSelected());
if (handles_wanted && !m_left_resize_handle)
addResizeHandles();
else if (!handles_wanted && m_left_resize_handle)
removeResizeHandles();
}
/**
@brief DynamicElementTextItem::addResizeHandles
Create and show the two width-resize handles (left/right edge of
frameRect()), reusing QetGraphicsHandlerItem the same way QetShapeItem
does for its own resize handles.
*/
void DynamicElementTextItem::addResizeHandles()
{
if (m_left_resize_handle || !scene())
return;
qreal size = QETUtils::graphicsHandlerSize(this);
m_left_resize_handle = new QetGraphicsHandlerItem(size);
m_right_resize_handle = new QetGraphicsHandlerItem(size);
for (QetGraphicsHandlerItem *handle : {m_left_resize_handle, m_right_resize_handle})
{
scene()->addItem(handle);
handle->setColor(Qt::darkGreen);
handle->setZValue(zValue() + 1);
handle->installSceneEventFilter(this);
}
updateResizeHandlesPos();
}
/**
@brief DynamicElementTextItem::removeResizeHandles
*/
void DynamicElementTextItem::removeResizeHandles()
{
delete m_left_resize_handle;
delete m_right_resize_handle;
m_left_resize_handle = nullptr;
m_right_resize_handle = nullptr;
}
/**
@brief DynamicElementTextItem::updateResizeHandlesPos
Keep the two resize handles at the vertical middle of boundingRect()'s
left and right edges, in scene coordinates -- called on every paint() so
it stays correct across every kind of change that can move this item or
change its size (position, rotation, font, text, textWidth...) without
needing a dedicated hook for each one.
Deliberately boundingRect(), not frameRect(): frameRect() is a tight box
around the text's own natural (idealWidth()) size, re-centred inside
boundingRect() -- it does not grow with textWidth(). Once a text has
been widened, that leaves a growing gap between the tight frame and the
dashed selection outline QGraphicsView draws at boundingRect(), which is
the box a user actually sees and expects a resize handle to sit on
(qelectrotech#591, reported by @arummler: "the drag elements should be
on the border of the box"). boundingRect() reflects the full
textWidth() (it is QGraphicsTextItem's own, driven by the document's
laid-out size), so the handles now track the box that is visibly
resized rather than the text glyphs inside it.
*/
void DynamicElementTextItem::updateResizeHandlesPos()
{
if (!m_left_resize_handle || !m_right_resize_handle)
return;
QRectF br = boundingRect();
m_left_resize_handle->setPos(mapToScene(QPointF(br.left(), br.center().y())));
m_right_resize_handle->setPos(mapToScene(QPointF(br.right(), br.center().y())));
}
/**
@brief DynamicElementTextItem::handlerMousePressEvent
@param handle
@param event
*/
void DynamicElementTextItem::handlerMousePressEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event)
{
Q_UNUSED(handle)
//The actual property value, kept as-is (possibly -1, meaning "auto")
//so a later undo restores the exact original state rather than a
//synthesized fixed width.
m_resize_original_width = textWidth();
//A concrete baseline for the live drag's delta math, which can't
//start from -1.
m_resize_baseline_width = (m_resize_original_width < 0) ? frameRect().width() : m_resize_original_width;
m_resize_start_local_x = mapFromScene(event->scenePos()).x();
}
/**
@brief DynamicElementTextItem::handlerMouseMoveEvent
Live-resize the text while dragging, exactly like the element editor's
resize handles live-update geometry during a drag (undo is only pushed
on release). The drag delta is resolved in this item's own local
coordinates (not scene coordinates) so a rotated text box still resizes
along its own baseline.
@param handle
@param event
*/
void DynamicElementTextItem::handlerMouseMoveEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event)
{
qreal local_x = mapFromScene(event->scenePos()).x();
qreal delta = local_x - m_resize_start_local_x;
if (handle == m_left_resize_handle)
delta = -delta;
qreal new_width = qMax(m_resize_baseline_width + delta, qreal(10));
setTextWidth(new_width);
updateResizeHandlesPos();
}
/**
@brief DynamicElementTextItem::handlerMouseReleaseEvent
Push the same QPropertyUndoCommand the properties-panel width spinbox
already pushes (sources/ui/dynamicelementtextmodel.cpp) -- the value is
already applied live from the drag, so this only makes it undoable.
@param handle
@param event
*/
void DynamicElementTextItem::handlerMouseReleaseEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event)
{
Q_UNUSED(handle)
Q_UNUSED(event)
qreal new_width = textWidth();
if (!qFuzzyCompare(m_resize_original_width, new_width) && m_parent_element && m_parent_element->diagram())
{
auto *undo = new QPropertyUndoCommand(this, "textWidth", QVariant(m_resize_original_width), QVariant(new_width));
undo->setAnimated(true, false);
undo->setText(tr("Redimensionner un texte d'élément"));
m_parent_element->diagram()->undoStack().push(undo);
}
}
void DynamicElementTextItem::elementInfoChanged()
{
DiagramContext dc;
@@ -1129,9 +1333,15 @@ void DynamicElementTextItem::updateLabel()
if(m_text_from == ElementInfo && element) {
setPlainText(element->actualLabel());
QString new_label = element->actualLabel();
if (toPlainText() != new_label) {
setPlainText(new_label);
}
}
else if (m_text_from == CompositeText) {
// Use actualLabel() to ensure %{label} reflects the current
// resolved label (e.g. after a folio/page-number change)
dc.addValue(QStringLiteral("label"), element->actualLabel());
setPlainText(autonum::AssignVariables::replaceVariable(m_composite_text, dc));
}
}
@@ -29,6 +29,7 @@ class Element;
class Conductor;
class ElementTextItemGroup;
class CrossRefItem;
class QetGraphicsHandlerItem;
/**
@brief The DynamicElementTextItem class
@@ -120,6 +121,12 @@ class DynamicElementTextItem : public DiagramTextItem
void setRotationPointCenter(bool set);
bool rotationPointCenter() const;
//Called by Element::itemChange() when the PARENT's selection
//changes, so the parent can keep each of its texts' resize
//handles in sync with its own selection state. Public for that;
//see the .cpp for why it exists.
void refreshResizeHandlesVisibility();
protected:
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
@@ -151,6 +158,12 @@ class DynamicElementTextItem : public DiagramTextItem
void zoomToLinkedElement();
void parentElementRotationChanged();
void thisRotationChanged();
void addResizeHandles();
void removeResizeHandles();
void updateResizeHandlesPos();
void handlerMousePressEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
void handlerMouseMoveEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
void handlerMouseReleaseEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
private:
QPointer <Element>
@@ -182,6 +195,11 @@ class DynamicElementTextItem : public DiagramTextItem
bool m_rotation_point_center = false;
qreal m_visual_rotation_ref = 0;
bool m_move_parent = true;
QetGraphicsHandlerItem *m_left_resize_handle = nullptr;
QetGraphicsHandlerItem *m_right_resize_handle = nullptr;
qreal m_resize_original_width = -1;
qreal m_resize_baseline_width = -1;
qreal m_resize_start_local_x = 0;
};
#endif // DYNAMICELEMENTTEXTITEM_H
+25
View File
@@ -1664,6 +1664,31 @@ void Element::hoverLeaveEvent(QGraphicsSceneHoverEvent *e)
update();
}
/**
@brief Element::itemChange
On ItemSelectedHasChanged, tell each of this element's own dynamic texts
to re-check whether its resize handles should be showing --
DynamicElementTextItem::refreshResizeHandlesVisibility() shows them when
either the text itself or its parent (this) is selected. An ordinary
click with no Shift selects the parent, not the text
(DynamicElementTextItem::mousePressEvent() forwards it), so without this
a plain click on a symbol never showed the resize handles this PR adds
to its texts (qelectrotech#591, reported by @arummler) -- only
Shift+click or a right-click's context menu did, since those are the
paths that leave the text itself selected.
*/
QVariant Element::itemChange(GraphicsItemChange change, const QVariant &value)
{
if (change == QGraphicsItem::ItemSelectedHasChanged)
{
const QList<DynamicElementTextItem *> texts = dynamicTextItems();
for (DynamicElementTextItem *deti : texts) {
deti->refreshResizeHandlesVisibility();
}
}
return QetGraphicsItem::itemChange(change, value);
}
/**
@brief Element::setUpFormula
Set up the formula used to create the label of this element
+1
View File
@@ -255,6 +255,7 @@ class Element : public QetGraphicsItem
QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent *) override;
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
protected:
//ATTRIBUTES related to linked element
+2
View File
@@ -37,6 +37,7 @@ namespace QET {
QIcon ConductorEdit;
QIcon ConductorSettings;
QIcon Configure;
QIcon ConfigureShortcuts;
QIcon ConfigureToolbars;
QIcon IC_CopyFile;
QIcon DefaultConductor;
@@ -408,6 +409,7 @@ void QET::Icons::initIcons()
ConductorEdit = QIcon::fromTheme("conductor-edit");
ConductorSettings = QIcon::fromTheme("conductor-reset");
Configure = QIcon::fromTheme("configure");
ConfigureShortcuts = QIcon::fromTheme("configure-shortcuts");
ConfigureToolbars = QIcon::fromTheme("configure-toolbars");
IC_CopyFile = QIcon::fromTheme("item-copy");
DiagramAdd = QIcon::fromTheme("folio-new");
+1
View File
@@ -44,6 +44,7 @@ namespace QET {
extern QIcon ConductorEdit;
extern QIcon ConductorSettings;
extern QIcon Configure;
extern QIcon ConfigureShortcuts;
extern QIcon ConfigureToolbars;
extern QIcon IC_CopyFile;
extern QIcon DefaultConductor;
+73
View File
@@ -17,8 +17,13 @@
*/
#include "qetpalette.h"
#include <QApplication>
#include <QColor>
#include <QImage>
#include <QMdiArea>
#include <QStyle>
#include <QTabBar>
#include <QWidget>
#include <cmath>
namespace {
@@ -75,6 +80,57 @@ bool QET::Palette::isDark(const QPalette &palette)
return palette.color(QPalette::Active, QPalette::Window).lightness() < 128;
}
void QET::Palette::invertLightness(QImage &image, const QColor &sheet,
const QColor &ink)
{
if (image.format() != QImage::Format_RGB32)
image.convertTo(QImage::Format_RGB32);
// One table per channel maps the inverted value (0 = was white,
// 255 = was black) onto the sheet..ink span.
uchar red_of[256], green_of[256], blue_of[256];
for (int v = 0; v < 256; ++v) {
red_of[v] = uchar(sheet.red() + (ink.red() - sheet.red()) * v / 255);
green_of[v] = uchar(sheet.green() + (ink.green() - sheet.green()) * v / 255);
blue_of[v] = uchar(sheet.blue() + (ink.blue() - sheet.blue()) * v / 255);
}
/* Inverting the lightness of an HSL color while keeping its hue and
saturation leaves the distance between the highest and the lowest
channel unchanged, so it comes down to one offset per pixel:
c + 255 - max - min. The offset turns the highest channel into
255 - min and the lowest into 255 - max, so no channel can leave
the 0..255 range and no clamping is needed. The loop runs on every
repaint of a folio, hence the plain integer arithmetic. */
for (int y = 0; y < image.height(); ++y) {
quint32 *line = reinterpret_cast<quint32 *>(image.scanLine(y));
for (int x = 0, width = image.width(); x < width; ++x) {
const quint32 pixel = line[x];
const int red = (pixel >> 16) & 0xff;
const int green = (pixel >> 8) & 0xff;
const int blue = pixel & 0xff;
int highest = red > green ? red : green;
int lowest = red < green ? red : green;
if (blue > highest) highest = blue;
if (blue < lowest) lowest = blue;
const int offset = 255 - highest - lowest;
line[x] = 0xff000000u
| (quint32(red_of[red + offset]) << 16)
| (quint32(green_of[green + offset]) << 8)
| quint32(blue_of[blue + offset]);
}
}
}
QColor QET::Palette::gridDotColor(const QColor &sheet, bool inverted)
{
if (sheet == QColor(Qt::black))
return Qt::white;
if (inverted)
return QColor(sheet.red() * 2 / 3, sheet.green() * 2 / 3, sheet.blue() * 2 / 3);
return Qt::black;
}
double QET::Palette::contrastRatio(const QColor &a, const QColor &b)
{
double lighter = relativeLuminance(a);
@@ -243,3 +299,20 @@ QPixmap QET::Palette::forPalette(const QPixmap &pixmap, const QPalette &palette)
result.setDevicePixelRatio(pixmap.devicePixelRatio());
return result;
}
void QET::Palette::refreshStyleSheets()
{
// Setting the same sheet again is not a no-op: QWidget::setStyleSheet()
// asks QStyleSheetStyle to repolish the widget, which recomputes its
// palette from the application palette now in force.
const QWidgetList widgets = QApplication::allWidgets();
for (QWidget *widget : widgets)
if (!widget->styleSheet().isEmpty())
widget->setStyleSheet(widget->styleSheet());
// Force an immediate repaint on tab bars and MDI areas so their text
// updates together with the rest of the UI, not one event loop later.
for (QWidget *widget : widgets)
if (qobject_cast<QTabBar *>(widget) || qobject_cast<QMdiArea *>(widget))
widget->update();
}
+35
View File
@@ -22,6 +22,7 @@
#include <QPalette>
#include <QPixmap>
class QImage;
class QStyle;
/**
@@ -54,6 +55,28 @@ namespace QET {
*/
bool isDark(const QPalette &palette);
/**
Invert the lightness of every pixel of \a image, keeping its hue
and saturation, then stretch the result between two colors: pure
white becomes \a sheet, pure black becomes \a ink, and a red
line stays red, only lighter. Made for a rendering of a white
sheet that has to read on a dark palette, with sheet = Base and
ink = Text. The image must be opaque; an image in another format
is converted to RGB32 first.
*/
void invertLightness(QImage &image, const QColor &sheet = Qt::black,
const QColor &ink = Qt::white);
/**
The color of the grid dots on a sheet of color \a sheet: black,
or white on a black sheet. With \a inverted the sheet is about to
be shown with its lightness inverted (PaletteGraphicsView), where
black dots would come out as bright as the ink; the dots are then
a third of the way from the sheet color to black, which shows as
a soft gray.
*/
QColor gridDotColor(const QColor &sheet, bool inverted);
/**
WCAG 2 contrast ratio between two opaque colors, from 1 (equal)
to 21 (black on white). Normal text needs at least 4.5, large
@@ -112,6 +135,18 @@ namespace QET {
left alone either way.
*/
QPixmap forPalette(const QPixmap &pixmap, const QPalette &palette);
/**
Make every widget that carries a style sheet take the current
application palette. QApplication::setPalette() reaches plain
widgets, but a widget with a style sheet keeps the palette
QStyleSheetStyle resolved when the sheet was applied, so after a
live light/dark switch it is drawn in the old colors (the folio
tab bar and its buttons, the element info widgets, several
configuration pages). Re-applying each widget's own sheet makes
QStyleSheetStyle resolve it again. Call after setPalette().
*/
void refreshStyleSheets();
}
}
+36 -1
View File
@@ -83,6 +83,30 @@ m_project_properties_handler{this}
m_default_guides.append(g);
}
settings.endArray();
//Load global auto-numbering defaults from QSettings
{
auto conductorData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/conductor"));
for (auto it = conductorData.first.constBegin(); it != conductorData.first.constEnd(); ++it) {
addConductorAutoNum(it.key(), it.value());
}
if (!conductorData.second.isEmpty()) {
setCurrentConductorAutoNum(conductorData.second);
}
auto elementData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/element"));
for (auto it = elementData.first.constBegin(); it != elementData.first.constEnd(); ++it) {
addElementAutoNum(it.key(), it.value());
}
if (!elementData.second.isEmpty()) {
setCurrrentElementAutonum(elementData.second);
}
auto folioData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/folio"));
for (auto it = folioData.first.constBegin(); it != folioData.first.constEnd(); ++it) {
addFolioAutoNum(it.key(), it.value());
}
}
}
ProjectPropertiesHandler &QETProject::projectPropertiesHandler()
@@ -310,7 +334,18 @@ QETProject::ProjectState QETProject::openFile(QFile *file)
//file without a persisted uuid derives its uuid from them.
const QByteArray content = file->readAll();
QDomDocument xml_project;
if (!xml_project.setContent(content))
// PreserveSpacingOnlyNodes: without it, a text node that is entirely
// whitespace -- e.g. a title-block custom variable deliberately set to
// a single space, the only way to give it a value other than blank
// (bugtracker #973) -- is silently dropped by Qt's default parsing,
// and QDomElement::text() then returns "" for it exactly as if it had
// never been set. Confirmed in isolation: <a> </a> parses to text()=="",
// this option makes it text()==" ". Every place in this codebase that
// walks a QDomNode's children already filters on isElement() (see
// QET::findInDomElement()), so the extra whitespace-only text nodes
// this keeps around are inert everywhere but the two elements that
// call .text() on themselves -- which is exactly where the bug was.
if (!xml_project.setContent(content, QDomDocument::ParseOption::PreserveSpacingOnlyNodes))
{
if(opened_here) {
file->close();
+11 -4
View File
@@ -67,20 +67,27 @@ bool QETStyle::isLineArt(const QImage &image)
/**
@brief QETStyle::hoverColor
The palette's highlight color is the accent users already know from
selections. On a dark palette it is too dark to read on a hovered
button face, so it is lightened, a step at a time, until it reaches
selections, moved away from the hovered button face until it reaches
3:1 (WCAG 1.4.11) against the Light role: Fusion paints a hovered
auto-raise button with a gradient that runs from Button up to about
that color, and the icon has to read on the lightest part of it.
that color, and the icon has to read on the lightest part of it. On a
dark face the accent is lightened, a step at a time; on a light face it
is darkened, which keeps a pale accent (macOS's green or yellow
selection color, which comes with black selection text) from being
pushed to white. Should twenty steps not get there, the button text
color serves, which reads on the face by construction.
*/
QColor QETStyle::hoverColor(const QPalette &palette)
{
const QColor face = palette.color(QPalette::Active, QPalette::Light);
const bool light_face = face.lightnessF() > 0.5;
QColor ink = palette.color(QPalette::Active, QPalette::Highlight);
// 3.5 rather than 3.0: the top of Fusion's hover gradient is a shade
// lighter than the Light role, so the icon needs some headroom there.
for (int step = 0; step < 20 && QET::Palette::contrastRatio(ink, face) < 3.5; ++step)
ink = ink.lighter(110);
ink = light_face ? ink.darker(110) : ink.lighter(110);
if (QET::Palette::contrastRatio(ink, face) < 3.5)
ink = palette.color(QPalette::Active, QPalette::ButtonText);
return ink;
}
File diff suppressed because it is too large Load Diff
+438 -5
View File
@@ -21,10 +21,19 @@
#include <QObject>
#include <QString>
#include <QStringList>
#include <QVariantList>
#include <QVariantMap>
class QETProject;
class DiagramView;
class Element;
class Terminal;
class Conductor;
class IndependentTextItem;
class QetShapeItem;
class DiagramImageItem;
class DynamicElementTextItem;
class QetGraphicsTableItem;
/**
@brief The QetScriptApi class
@@ -65,12 +74,246 @@ class Element;
QPropertyUndoCommand merges consecutive commands on the same
object+property when their text() also matches
(QPropertyUndoCommand::mergeWith(), pre-existing), and
setElementPosition()/moveElement() always use the same text for a
given element -- so several position changes to the same element in a
row collapse into one undo step, the same way dragging an element
does, not one step per call. Verified against exactly that: two
setElementPosition()/moveElement()/rotateElement() always use the
same text for a given element -- so several position changes, or
several rotations, of the same element in a row collapse into one
undo step, the same way dragging or repeatedly rotating an element
does, not one step per call. setElementInfo()/setElementLabel()
behave the same way for the same reason, through
ChangeElementInformationCommand::mergeWith(). Verified against exactly that: two
consecutive calls on one element, then undo/undo/redo/redo, land
where a merge predicts, not where two independent steps would.
- @b Wiring, @b labelling and @b folios: create a conductor between two
terminals (ConductorCreator, the same class the GUI's
drag-a-rectangle-over-terminals path uses, so the result inherits an
existing potential's properties and joins conductor auto-numbering),
change an element's label or any other information key
(ChangeElementInformationCommand, which also tells the project
database what changed), add a folio (QETProject::addNewDiagram(),
already undoable) and set its title (ChangeTitleBlockCommand). With
addElement() these are what make a script able to draw rather than
only rearrange: before them a script could place two symbols and had
no way to connect them.
Terminals are addressed by their @b index in Element::terminals(),
not by uuid, and elementTerminals() prints that indexing so a script
can see what it is about to wire. The index is the terminal's place
in the element's own top-to-bottom, left-to-right ordering, not the
order its definition file lists them. Terminal uuids look like the
obvious key and are not one: Terminal::uuid() is a property of the
catalog .elmt definition, empty for most of the installed base and,
where present, identical across every instance of that element -- so
it does not distinguish one placed coil's A1 from another's.
- @b Conductor properties and @b cross-references: set a conductor's
number, formula, colour or section (and its look: style normal/
dashed/dashdotted, two-colour mode and second colour, dash size,
line width, text size, whether its number is shown -- all under the
names the .qet file uses for them), and link a master to a slave or
one report to another. Both follow the application's own rules rather
than writing the field: a conductor property is applied to every
conductor of the same electrical potential, which is what the GUI and
search-and-replace both do -- a wire number belongs to a potential,
not to one drawn segment -- and a link is refused unless
LinkElementCommand::isLinkable() allows it, which is where the
master/slave, PLC-pairing and report-direction rules already live.
linkElements() takes a folio index for each end because a master and
its slave are usually on different ones.
A conductor is addressed as "the conductor on terminal i of element
U", not by an identity of its own: conductors have no persisted uuid,
and the folio-scoped integer ids the file uses for their ends are
renumbered on every save, so there is nothing stable to name one by.
Since the change is potential-wide anyway, any terminal of the
potential names it equally well. A terminal carrying more than one
conductor is ambiguous and is refused rather than guessed at -- which
in practice means a potential is addressed from one of its leaf
terminals, not from the hub several conductors meet at.
- @b Text and @b shapes: the drawing furniture a folio carries beside
its circuit -- a free-standing note, a line, a rectangle, an ellipse
-- added with the same AddGraphicsObjectCommand the corresponding GUI
tools use, and changed through the plainText/color/rotation
properties those items already publish. A shape's look is set with
setShapeProperty(): color and fill (a colour name, or "none" for no
fill), width, line-style (solid, dashed, dotted, dashdot) and
rotation, through the pen/brush/rotation properties the shape's own
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.
- @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
"Requête SQL personnalisée" box in the element-query dialog where a
user types arbitrary SQL, and it is guarded by the same
projectDataBase::isReadOnlySelect() this calls through
projectDataBase::newQuery(). A script gets what a user already has,
under the same rule, and neither can write.
What is worth knowing is what the database @b is: a cache, rebuilt
from the XML on every load and never written to disk. The three
views -- element_nomenclature_view, project_summary_view and
wiring_list_view -- exist to be queried and are the surface to
depend on. The underlying tables are how the cache happens to be
arranged today, and a column may move. tables() lists both so a
script can see what it is querying rather than guess.
- @b Terminal @b strips: create a strip, put terminal-type elements on
it, remove it. Strips are addressed by index into terminalStrips(),
which is the project's own order (unlike texts and shapes it is not
re-sorted) and does shift when one is removed. Only elements whose
link type is "terminal" can be added, the same restriction the
editor enforces by construction.
stripRealTerminals() lists the strip's real terminals -- the actual
wire-ends added by addTerminalToStrip(), one per index -- which
physical position (clamp) each currently sits on and how many
neighbours share it, since that is what groupTerminals() and
bridgeTerminals() address by index into.
groupTerminals() merges several real terminals onto one physical
position, choosing the receiving position the same way the terminal
strip editor's own "group" button does: the position among the ones
named that already carries the most real terminals, not necessarily
the first one given -- a script asking to group indices [0, 1] is not
guaranteed index 0's position is where they end up. bridgeTerminals()
wires several real terminals together electrically without merging
their positions, refused (TerminalStrip::isBridgeable()) when they
are not all at the same level -- the same check the editor's bridge
button applies, not a rule reimplemented here. sortTerminalStrip()
reorders the strip's physical positions into the canonical order the
editor's own sort button computes.
- @b Tables: a BOM/nomenclature or a summary (table of contents) placed
on a folio, through QetGraphicsTableFactory::create() -- the same
factory call the "add table" menu action makes, minus the modal
AddTableDialog it collects its settings from first. That dialog is
still built here, off-screen and never shown or exec'd: addTable()
calls setTableName() and the query widget's setQuery() on it, the
same as a user filling in the form, and forces its two checkboxes
("adjust table to folio" and "add a new folio if the table overflows"
it) off regardless of their .ui-file default of checked -- a script
calling addTable() once should create exactly the one table it asked
for, not possibly several spread across folios it never asked to add.
A script that wants either behaviour can resize the result itself or
add its own folio.
Neither creating nor deleting a table is undoable:
QetGraphicsTableFactory::newTable(), which create() calls, calls
Diagram::addItem() directly, with no undo command of its own, in the
stock "add table" action as much as here -- a pre-existing gap in the
application, not something introduced by this API. Tables are
addressed by index in a position-sorted listing, like texts, shapes
and images.
- @b Auto-numbering: define a named numbering context of kind
"conductor", "element" or "folio", built from parts written
"type[:value[:increase]]" -- types are the ones the auto-numbering
dialog offers (string, unit, ten, hundred, alpha, idfolio, folio,
plant, locmach, elementline, elementcolumn, elementprefix, wrap,
unitfolio, tenfolio, hundredfolio) -- and select which one a folio's
new conductors use. Defining or removing a context is not undoable,
because the application itself does it through direct project calls
and only the counter advance is on the undo stack; the numbering
actually applied to a conductor is.
For elements, useElementAutoNum() selects the current context and
numberElement() applies it to one element, as the "add element" tool
does right after placing one. addElement() deliberately does not
number what it places: doing it silently would change what an existing
script produces the moment its project happens to have a context
selected, so it is a separate, explicit call. Folio auto-numbering is
not offered: in the application it spawns whole new folios from a
context, which is a different operation from labelling.
- @b Duplicating: copy elements, together with the conductors that run
between them, to a position on the same or another folio, through
Diagram::toXml() and fromXml() and PasteDiagramCommand -- what Ctrl+C
and Ctrl+V do, so a paste behaves as a paste does there: the copies
come without their labels and without their conductors' wire numbers,
which the application clears on paste (measured: '' on both).
The position is the top left of the pasted group's bounding rectangle,
so an element's own origin ends up offset from it by its hotspot
(measured: +20, +30 for a coil); (0, 0) is not a position but means
"keep the source coordinates", as Diagram::fromXml() treats it. The
result lists the copies in the order the elements were named --
the application's own list is in scene order, and a caller pairing by
index would otherwise be wired to the wrong copies -- paired by
position, which a paste preserves, so two elements at the same point
cannot be told apart. A conductor is copied
only if both its ends are among the copied elements. The previous
selection is put back afterwards, since copying works by selecting.
- @b Project title and folio frame: setProjectTitle(), and the grid that
frames each folio -- columns and rows, their size, and whether the
headers show (columns, column-width, display-columns, rows, row-height,
display-rows) -- through ChangeBorderCommand. These are the six fields
the folio properties panel offers; the title block's header sizes,
which it does not, are left alone. Changing the project title is not
undoable: the application sets it directly too.
A folio's title block @b template is a seventh, separate case:
Diagram::setTitleBlockTemplate() resolves a name only against
QETProject::embeddedTitleBlockTemplatesCollection() -- the same
copy-into-the-project step addElement() already does for elements,
and for the same reason (a project opened on another machine must not
depend on files only this one has). titleBlockTemplates() lists what
is embedded and what is available to embed from the common/company
/custom collections, each name suffixed with its source;
embedTitleBlockTemplate() does the copy (QDomElement in, unmodified,
via *TemplatesCollection::get/setTemplateXmlDescription() -- neither
side is scripting-specific code, both already exist for the template
editor to call). setFolioProperty(folio, "template", name) then
embeds it first if it is not already, refusing only if no collection
has that name at all. Embedding is not undoable, the same as defining
an auto-numbering context is not: the application does both through
direct collection/project calls with no undo command of their own.
A template literally named "default" reads back as folioProperty()
"" afterwards, not "default": BorderTitleBlock::titleBlockTemplateName()
treats the two as the same thing, since "no override" already renders
with the template named "default".
- @b Geometry and folio order: elementGeometry() reads where an element
is -- x, y (its origin), rotation, and the box it occupies on the folio
(left, top, right, bottom) -- so a script can lay one thing out relative
to another instead of only setting absolute coordinates, and can check
that a move landed. insertFolio() puts a new folio at a position
instead of at the end, which is what reordering is mostly for while
moving an existing folio still needs the application's project view.
- @b Images: place a picture from a file. The pixels are copied into
the project, which stores them inline in the .qet -- the saved file
does not refer to the original path, so it opens on another machine,
and it grows by roughly the size of the image, which is why files
over 10 MB are refused. Images are addressed by index in a
position-sorted listing, like texts and shapes -- by the on-screen
bounding box, so scaling or rotating an image, which turns about its
centre, can change where it sorts. Re-list after either.
- @b Element @b texts: the text fields drawn on a symbol -- its label,
the names beside its terminals, any value the definition placed there.
A symbol arrives with the fields its definition gives it; setElementLabel()
fills the value one of them shows, and these methods control the fields
themselves: where each sits, its size, whether it draws a frame, what it
shows, and adding or deleting one. Addressed by index in the element's
own list, which follows the definition's order and shifts when one is
deleted -- and undoing a deletion puts the field back at the end, so
list again after either.
Two things called text, which differ for a field bound to an
information key: the @b "text" property is the field's stored string,
which for an information-bound field is an unused placeholder (empty,
or "Texte" once one has been added), and @b "shows" is what is drawn,
which follows the element's information straight away -- compared
against elementInfo() at seven points across relabel, rebinding,
setting and undo, with no difference. Read "shows".
Consecutive setElementInfo()/setElementLabel() calls on one element
merge into a single undo step, as ChangeElementInformationCommand
does, so one undo can revert several.
A field's @b source is "text" (a fixed string), "info" (the value of one
of the element's information keys, so it follows setElementInfo() and
setElementLabel()) or "composite" (a formula over several). Position is in
the element's own coordinates, not the folio's.
- @b Navigating and @b messaging: select an element, zoom the active
view, and show the user a message. Deliberately narrow: selection and
messaging work with no view at all (headless `--run`); zoom is a no-op
@@ -87,7 +330,11 @@ class Element;
import-collision case that would otherwise reach
QETProject::importElement()'s own ImportElementDialog::exec() and
refuses instead, rather than let a plain QDialog (not routed through
QetMessageBox) block a script the same way.
QetMessageBox) block a script the same way. addConductor() declines the
same way, for the same reason, when the two terminals belong to two
different existing potentials and ConductorCreator would therefore ask
which one's properties to inherit -- measured: with that check removed,
exactly that call never returns.
*/
class QetScriptApi : public QObject
{
@@ -128,15 +375,187 @@ class QetScriptApi : public QObject
Q_INVOKABLE QString addElement(int folioIndex, const QString &locationPath, double x, double y);
Q_INVOKABLE bool setElementPosition(int folioIndex, const QString &elementUuid, double x, double y);
Q_INVOKABLE bool moveElement(int folioIndex, const QString &elementUuid, double dx, double dy);
Q_INVOKABLE bool rotateElement(int folioIndex, const QString &elementUuid, double angle);
Q_INVOKABLE bool deleteElement(int folioIndex, const QString &elementUuid);
// -- address what is already there --
Q_INVOKABLE QStringList elementUuids(int folioIndex) const;
Q_INVOKABLE QString elementName(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE QStringList elementTerminals(int folioIndex, const QString &elementUuid) const;
// -- element information, through ChangeElementInformationCommand --
Q_INVOKABLE QString elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const;
Q_INVOKABLE bool setElementInfo(int folioIndex, const QString &elementUuid, const QString &key, const QString &value);
Q_INVOKABLE QString elementLabel(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE bool setElementLabel(int folioIndex, const QString &elementUuid, const QString &label);
// -- wire two terminals together --
Q_INVOKABLE bool addConductor(int folioIndex,
const QString &elementUuidA, int terminalIndexA,
const QString &elementUuidB, int terminalIndexB);
// -- conductor properties, applied to the whole potential --
Q_INVOKABLE QStringList conductors(int folioIndex) const;
Q_INVOKABLE QString conductorProperty(int folioIndex, const QString &elementUuid,
int terminalIndex, const QString &property) const;
Q_INVOKABLE bool setConductorProperty(int folioIndex, const QString &elementUuid,
int terminalIndex, const QString &property,
const QString &value);
// -- a conductor's own drawn path, not the whole potential's
// properties above -- one conductor only, addressed the same way --
Q_INVOKABLE QStringList conductorSegments(int folioIndex, const QString &elementUuid,
int terminalIndex) const;
Q_INVOKABLE bool moveConductorSegment(int folioIndex, const QString &elementUuid,
int terminalIndex, int segmentIndex,
double dx, double dy);
// -- cross-references: master/slave and report links --
Q_INVOKABLE QString elementLinkType(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE QStringList linkedElements(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE bool linkElements(int folioIndexA, const QString &elementUuidA,
int folioIndexB, const QString &elementUuidB,
int groupIndex = -1);
Q_INVOKABLE bool unlinkElement(int folioIndex, const QString &elementUuid);
Q_INVOKABLE int elementLinkGroupIndex(int folioIndex, const QString &elementUuid,
int otherFolioIndex, const QString &otherElementUuid) const;
// -- a PLC master's IO table: address/function/comment rows a PLC
// slave links onto via linkElements()'s groupIndex --
Q_INVOKABLE QStringList plcIOs(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE int addPlcIO(int folioIndex, const QString &elementUuid, const QString &type,
const QString &address, const QString &functionText,
const QString &comment);
Q_INVOKABLE bool setPlcIO(int folioIndex, const QString &elementUuid, int ioIndex,
const QString &property, const QString &value);
Q_INVOKABLE bool removePlcIO(int folioIndex, const QString &elementUuid, int ioIndex);
// -- independent text and drawing shapes --
Q_INVOKABLE QStringList texts(int folioIndex) 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);
Q_INVOKABLE bool setTextColor(int folioIndex, int textIndex, const QString &color);
Q_INVOKABLE bool setTextRotation(int folioIndex, int textIndex, double angle);
Q_INVOKABLE bool deleteText(int folioIndex, int textIndex);
Q_INVOKABLE QStringList shapes(int folioIndex) 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);
Q_INVOKABLE QString shapeProperty(int folioIndex, int shapeIndex, const QString &property) const;
Q_INVOKABLE bool setShapeProperty(int folioIndex, int shapeIndex,
const QString &property, const QString &value);
// -- polygon and path shapes: more than addShape()'s two-point box --
Q_INVOKABLE int addPolygon(int folioIndex, const QVariantList &points, bool closed);
Q_INVOKABLE QVariantList shapePolygon(int folioIndex, int shapeIndex) const;
Q_INVOKABLE bool setShapePolygon(int folioIndex, int shapeIndex, const QVariantList &points);
Q_INVOKABLE int addPath(int folioIndex, const QVariantList &nodes, bool closed);
Q_INVOKABLE QVariantList shapePathNodes(int folioIndex, int shapeIndex) const;
Q_INVOKABLE bool setShapePathNodes(int folioIndex, int shapeIndex, const QVariantList &nodes);
Q_INVOKABLE bool setShapeClosed(int folioIndex, int shapeIndex, bool closed);
// -- query the project database --
Q_INVOKABLE QStringList tables() const;
Q_INVOKABLE QVariantList query(const QString &sql);
Q_INVOKABLE QString queryError() const;
// -- removing a conductor or a folio; folio properties beyond the title --
Q_INVOKABLE bool deleteConductor(int folioIndex, const QString &elementUuid, int terminalIndex);
Q_INVOKABLE bool removeFolio(int folioIndex);
Q_INVOKABLE bool setFolioProperty(int folioIndex, const QString &property, const QString &value);
Q_INVOKABLE QString folioProperty(int folioIndex, const QString &property) const;
// -- terminal strips (borniers) --
Q_INVOKABLE QStringList terminalStrips() const;
Q_INVOKABLE int addTerminalStrip(const QString &installation, const QString &location,
const QString &name);
Q_INVOKABLE bool removeTerminalStrip(int stripIndex);
Q_INVOKABLE bool addTerminalToStrip(int stripIndex, int folioIndex,
const QString &elementUuid);
Q_INVOKABLE QStringList stripRealTerminals(int stripIndex) const;
Q_INVOKABLE bool groupTerminals(int stripIndex, const QVariantList &realTerminalIndices);
Q_INVOKABLE bool bridgeTerminals(int stripIndex, const QVariantList &realTerminalIndices);
Q_INVOKABLE bool sortTerminalStrip(int stripIndex);
// -- a BOM/nomenclature or summary table placed on a folio --
Q_INVOKABLE QStringList tables(int folioIndex) const;
Q_INVOKABLE int addTable(int folioIndex, const QString &kind, const QString &name,
const QString &query);
Q_INVOKABLE bool deleteTable(int folioIndex, int tableIndex);
Q_INVOKABLE bool setTablePosition(int folioIndex, int tableIndex, double x, double y);
// -- auto-numbering contexts (conductor, element, folio) --
Q_INVOKABLE QStringList autoNums(const QString &kind) const;
Q_INVOKABLE bool addAutoNum(const QString &kind, const QString &name, const QStringList &parts);
Q_INVOKABLE bool removeAutoNum(const QString &kind, const QString &name);
Q_INVOKABLE bool useConductorAutoNum(int folioIndex, const QString &name);
Q_INVOKABLE bool useElementAutoNum(const QString &name);
Q_INVOKABLE bool numberElement(int folioIndex, const QString &elementUuid);
// -- images, embedded in the project --
Q_INVOKABLE QStringList images(int folioIndex) 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);
Q_INVOKABLE bool deleteImage(int folioIndex, int imageIndex);
Q_INVOKABLE int addPdfPage(int folioIndex, const QString &pdfPath, int pageNumber,
int dpi, double x, double y);
// -- the text fields shown on a symbol (label, terminal names, ...) --
Q_INVOKABLE QStringList elementTexts(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE int addElementText(int folioIndex, const QString &elementUuid,
const QString &source, const QString &value,
double x, double y);
Q_INVOKABLE bool setElementTextProperty(int folioIndex, const QString &elementUuid,
int textIndex, const QString &property,
const QString &value);
Q_INVOKABLE QString elementTextProperty(int folioIndex, const QString &elementUuid,
int textIndex, const QString &property) const;
Q_INVOKABLE bool deleteElementText(int folioIndex, const QString &elementUuid, int textIndex);
// -- copy elements (with the conductors between them) to a position --
Q_INVOKABLE QStringList duplicateElements(int fromFolioIndex, const QStringList &elementUuids,
int toFolioIndex, double x, double y);
// -- the project title, and each folio's frame (grid of columns and rows) --
Q_INVOKABLE bool setProjectTitle(const QString &title);
Q_INVOKABLE QString folioBorder(int folioIndex, const QString &property) const;
Q_INVOKABLE bool setFolioBorder(int folioIndex, const QString &property, const QString &value);
// -- title block templates: which exist, embedding one into the project --
Q_INVOKABLE QStringList titleBlockTemplates() const;
Q_INVOKABLE bool embedTitleBlockTemplate(const QString &name);
// -- read an element's geometry --
Q_INVOKABLE QVariantMap elementGeometry(int folioIndex, const QString &elementUuid) const;
// -- folios --
Q_INVOKABLE int addFolio();
Q_INVOKABLE int insertFolio(int position);
Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title);
Q_INVOKABLE bool undo();
Q_INVOKABLE bool redo();
Q_INVOKABLE bool canUndo() const;
Q_INVOKABLE bool canRedo() const;
// -- project-wide text search & replace, one undo step for the
// whole run, in the same spirit as the "Search and replace" panel --
Q_INVOKABLE int searchAndReplace(const QString &kind, const QString &field,
const QString &pattern, const QString &replacement,
bool useRegex, bool caseSensitive);
// -- electrical continuity / ERC: read-only, structural checks
// against the live object graph rather than the XML -- see the
// .cpp doc comment for exactly what is and is not covered --
Q_INVOKABLE QVariantList checkContinuity(int folioIndex);
// -- navigate and message --
Q_INVOKABLE bool selectElement(const QString &elementUuid);
Q_INVOKABLE void deselectAll(int folioIndex);
Q_INVOKABLE QStringList selectedElements(int folioIndex) const;
Q_INVOKABLE bool zoomFit();
Q_INVOKABLE bool zoomToContent();
Q_INVOKABLE bool zoomReset();
@@ -148,9 +567,23 @@ class QetScriptApi : public QObject
private:
bool runFlag(const QString &flag, const QStringList &args);
Element *findElement(int folioIndex, const QString &elementUuid) const;
Terminal *findTerminal(int folioIndex, const QString &elementUuid, int terminalIndex,
const QString &caller);
Conductor *findConductor(int folioIndex, const QString &elementUuid, int terminalIndex,
const QString &caller);
QList<IndependentTextItem *> sortedTexts(int folioIndex) const;
QList<QetShapeItem *> sortedShapes(int folioIndex) const;
QList<QetGraphicsTableItem *> sortedTables(int folioIndex) const;
QList<DiagramImageItem *> sortedImages(int folioIndex) const;
DynamicElementTextItem *findElementText(int folioIndex, const QString &elementUuid,
int textIndex, const QString &caller) const;
IndependentTextItem *findText(int folioIndex, int textIndex, const QString &caller);
bool setInfoKey(int folioIndex, const QString &elementUuid,
const QString &key, const QString &value, const QString &caller);
QETProject *m_project;
DiagramView *m_view;
QString m_query_error;
};
#endif // QET_SCRIPT_API_H
+40
View File
@@ -20,6 +20,7 @@
#include "qetscriptapi.h"
#include "../qetmessagebox.h"
#include "../qetproject.h"
#include "../utils/qetsettings.h"
#include <QFile>
#include <QFileInfo>
@@ -48,8 +49,33 @@ bool isRunRequest(const QStringList &args)
#ifdef QET_HAS_SCRIPTING
namespace {
/**
@brief refusalMessage
What to tell somebody whose script was not run, and how to change
that. Written once because the command line and the graphical
editor both need to say it, and an explanation that names only one
of the two ways out sends half the people down the wrong path.
*/
QString refusalMessage()
{
return QObject::tr(
"Les scripts sont désactivés.\n\n"
"Un script a accès à l'ensemble du projet et peut écrire des "
"fichiers, aussi cette fonction est-elle désactivée par défaut.\n\n"
"Pour l'activer : Configurer QElectroTech > Général > Projets, "
"ou définir la variable d'environnement QET_ENABLE_SCRIPTING=1 "
"pour une exécution sans interface (CI, traitement par lot).");
}
}
int run(const QStringList &args)
{
if (!QetSettings::scriptingEnabled()) {
err << refusalMessage() << "\n";
return 3;
}
const int idx = args.indexOf(QStringLiteral("--run"));
const QString script_path = args.value(idx + 1);
const QString project_path = args.value(idx + 2);
@@ -88,6 +114,20 @@ namespace {
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view)
{
// Checked here as well as at each caller, deliberately: this is the
// one function that actually evaluates JavaScript, so it is the one
// place a future caller cannot forget to ask. The callers check first
// only to give a better answer than this one can -- a usable exit code
// on the command line, an offer to switch the setting on in the editor.
if (!QetSettings::scriptingEnabled()) {
err << refusalMessage() << "\n";
if (view) {
QET::QetMessageBox::warning(nullptr, QObject::tr("Script"),
refusalMessage());
}
return false;
}
QFile file(scriptPath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
err << "Cannot open script: " << scriptPath << "\n";
+8 -3
View File
@@ -19,6 +19,7 @@
#include "NameList/nameslist.h"
#include "createdxf.h"
#include "diagram.h"
#include "qet.h"
#include "qetapp.h"
// uncomment the line below to get more debug information
@@ -1589,8 +1590,10 @@ void TitleBlockTemplate::render(QPainter &painter,
int titleblock_height = height();
painter.save();
//Setup the QPainter
QPen pen(Qt::black);
//Setup the QPainter - use a color that contrasts with the background
QColor ink = Diagram::background_color.lightness() < 128
? QColor(Qt::white) : QColor(Qt::black);
QPen pen(ink);
painter.setPen(pen);
// draw the titleblock border
@@ -1737,7 +1740,9 @@ void TitleBlockTemplate::renderCell(QPainter &painter,
{
// draw the border rect of the current cell
QPen pen(QBrush(), 1, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin);
pen.setColor(Qt::black);
QColor ink = Diagram::background_color.lightness() < 128
? QColor(Qt::white) : QColor(Qt::black);
pen.setColor(ink);
painter.setPen(pen);
painter.drawRect(cell_rect);
+173
View File
@@ -31,6 +31,8 @@
#include "../titleblockpropertieswidget.h"
#include "../xrefpropertieswidget.h"
#include "guidespropertieswidget.h"
#include "../autoNum/numerotationcontext.h"
#include "../autoNum/ui/selectautonumw.h"
#include <QFont>
#include <QFontDialog>
#include <QSizePolicy>
@@ -101,6 +103,32 @@ NewDiagramPage::NewDiagramPage(QETProject *project,
}
m_gpw->setGuides(loaded_guides);
// global auto-numbering defaults (only when editing global settings, not a project)
if (!m_project) {
auto saw_conductor = new SelectAutonumW(1);
auto saw_element = new SelectAutonumW(0);
auto saw_folio = new SelectAutonumW(2);
initAutoNumTab(m_autonum_conductor, saw_conductor, QStringLiteral("autonum/conductor"));
initAutoNumTab(m_autonum_element, saw_element, QStringLiteral("autonum/element"));
initAutoNumTab(m_autonum_folio, saw_folio, QStringLiteral("autonum/folio"));
QSettings autonum_settings;
loadAutoNumTab(m_autonum_conductor, autonum_settings);
loadAutoNumTab(m_autonum_element, autonum_settings);
loadAutoNumTab(m_autonum_folio, autonum_settings);
// Intercept Return key in the combo line edits so it doesn't
// activate the dialog's default button (OK).
for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) {
if (QComboBox *combo = tab->widget->contextComboBox()) {
if (combo->lineEdit()) {
combo->lineEdit()->installEventFilter(this);
}
}
}
}
//If there is a project, we edit his properties
if (m_project) {
bpw -> setProperties (m_project -> defaultBorderProperties());
@@ -114,6 +142,7 @@ NewDiagramPage::NewDiagramPage(QETProject *project,
// main tab widget
QTabWidget *tab_widget = new QTabWidget(this);
m_tab_widget = tab_widget;
QWidget *diagram_widget = new QWidget();
QVBoxLayout *diagram_layout = new QVBoxLayout(diagram_widget);
@@ -127,6 +156,19 @@ NewDiagramPage::NewDiagramPage(QETProject *project,
tab_widget -> addTab (xrefpw, tr("Références croisées"));
tab_widget -> addTab (m_gpw, tr("Guides"));
// add auto-numbering tab only for global settings (not per project)
if (!m_project) {
QWidget *autonum_widget = new QWidget();
QVBoxLayout *autonum_layout = new QVBoxLayout(autonum_widget);
autonum_layout->addWidget(new QLabel(tr("Définir les règles de numérotation automatique par défaut pour les nouveaux projets :")));
QTabWidget *autonum_inner_tab = new QTabWidget();
autonum_inner_tab->addTab(m_autonum_conductor.widget, tr("Conducteurs"));
autonum_inner_tab->addTab(m_autonum_element.widget, tr("Eléments"));
autonum_inner_tab->addTab(m_autonum_folio.widget, tr("Folios"));
autonum_layout->addWidget(autonum_inner_tab);
tab_widget -> addTab (autonum_widget, tr("Numérotation auto"));
}
QVBoxLayout *vlayout1 = new QVBoxLayout();
vlayout1->addWidget(tab_widget);
@@ -230,6 +272,9 @@ void NewDiagramPage::applyConf()
settings.setValue(QStringLiteral("color"), current_guides[i].color.name());
}
settings.endArray();
// save global auto-numbering defaults
persistAutonumSettings();
}
}
@@ -295,6 +340,134 @@ void NewDiagramPage::loadSavedTbp()
applyConf();
}
/**
@brief NewDiagramPage::isPlaceholder
Return true if @a name matches the combo box's built-in placeholder text
(first item). This is locale-independent because it reads the actual item text.
*/
bool NewDiagramPage::isPlaceholder(QComboBox *combo, const QString &name)
{
return !combo->count() || name == combo->itemText(0);
}
/**
@brief NewDiagramPage::initAutoNumTab
Initialise an AutoNumTab struct and connect its signals.
*/
void NewDiagramPage::initAutoNumTab(AutoNumTab &tab, SelectAutonumW *w, const QString &prefix)
{
tab.widget = w;
tab.prefix = prefix;
connect(w, &SelectAutonumW::applyPressed, this, [this, &tab]() { saveAutoNumContext(tab); });
connect(w, &SelectAutonumW::removeClicked, this, [this, &tab]() { removeAutoNumContext(tab); });
connect(w->contextComboBox(), &QComboBox::activated, this, [this, &tab](int index) {
if (index >= 0) {
QString name = tab.widget->contextComboBox()->itemText(index);
if (tab.contexts.contains(name)) {
tab.widget->setContext(tab.contexts.value(name));
}
}
});
}
/**
@brief NewDiagramPage::loadAutoNumTab
Load saved rules from QSettings into an AutoNumTab.
*/
void NewDiagramPage::loadAutoNumTab(AutoNumTab &tab, QSettings &settings)
{
auto data = NumerotationContext::loadFromSettings(settings, tab.prefix);
tab.contexts = data.first;
for (auto it = tab.contexts.constBegin(); it != tab.contexts.constEnd(); ++it) {
tab.widget->contextComboBox()->addItem(it.key());
}
if (!tab.contexts.isEmpty() && !data.second.isEmpty()
&& tab.contexts.contains(data.second)) {
tab.widget->contextComboBox()->setCurrentText(data.second);
tab.widget->setContext(tab.contexts.value(data.second));
}
}
/**
@brief NewDiagramPage::saveAutoNumContext
Save the current context from an AutoNumTab's widget into its hash and
persist to QSettings immediately.
*/
void NewDiagramPage::saveAutoNumContext(AutoNumTab &tab)
{
QString name = tab.widget->contextComboBox()->currentText().trimmed();
if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) {
return;
}
tab.contexts.insert(name, tab.widget->toNumContext());
if (tab.widget->contextComboBox()->findText(name) == -1) {
tab.widget->contextComboBox()->addItem(name);
}
persistAutonumSettings();
}
/**
@brief NewDiagramPage::removeAutoNumContext
Remove the current context from an AutoNumTab's hash and persist.
*/
void NewDiagramPage::removeAutoNumContext(AutoNumTab &tab)
{
QString name = tab.widget->contextComboBox()->currentText().trimmed();
if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) {
return;
}
int idx = tab.widget->contextComboBox()->findText(name);
if (idx == -1) return;
tab.contexts.remove(name);
tab.widget->contextComboBox()->removeItem(idx);
tab.widget->contextComboBox()->setCurrentText(QString());
tab.widget->setContext(NumerotationContext());
persistAutonumSettings();
}
/**
@brief NewDiagramPage::persistAutonumSettings
Save all autonum contexts to QSettings immediately.
*/
void NewDiagramPage::persistAutonumSettings()
{
QSettings settings;
for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) {
QString current;
QComboBox *combo = tab->widget->contextComboBox();
if (!isPlaceholder(combo, combo->currentText().trimmed())
&& tab->contexts.contains(combo->currentText().trimmed())) {
current = combo->currentText().trimmed();
}
NumerotationContext::saveToSettings(tab->contexts, current,
settings, tab->prefix);
}
}
/**
@brief NewDiagramPage::eventFilter
Intercept Return/Enter in combo box line edits so it doesn't close the
settings dialog.
*/
bool NewDiagramPage::eventFilter(QObject *obj, QEvent *event)
{
if (event->type() == QEvent::KeyPress) {
auto *ke = static_cast<QKeyEvent *>(event);
if ((ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter)) {
// Check if this is a line edit inside one of our autonum combos
for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) {
if (QComboBox *combo = tab->widget->contextComboBox()) {
if (combo->lineEdit() && combo->lineEdit() == obj) {
return true; // eat the event
}
}
}
}
}
return ConfigPage::eventFilter(obj, event);
}
/**
Constructeur
@param parent QWidget parent
+21
View File
@@ -20,6 +20,7 @@
#include "configpage.h"
#include "../projectpropertiesdialog.h"
#include "../titleblockpropertieswidget.h"
#include "../autoNum/numerotationcontext.h"
#include <QDialog>
#include <QtWidgets>
@@ -32,6 +33,7 @@ class XRefPropertiesWidget;
class GuidesPropertiesWidget;
class QETProject;
class TitleBlockProperties;
class SelectAutonumW;
/**
@brief The NewDiagramPage class
@@ -48,6 +50,7 @@ class NewDiagramPage : public ConfigPage {
~NewDiagramPage() override;
private:
NewDiagramPage(const NewDiagramPage &);
bool eventFilter(QObject *obj, QEvent *event) override;
public slots:
void changeToAutoFolioTab();
void setFolioAutonum(QString);
@@ -72,7 +75,25 @@ public slots:
XRefPropertiesWidget *xrefpw; ///< Widget to edit default xref properties
GuidesPropertiesWidget *m_gpw; ///< Widget to edit guides
TitleBlockProperties savedTbp; ///< Used to save current TBP and retrieve later
QTabWidget *m_tab_widget; ///< Main tab widget (stored for later access)
// auto-numbering tab data
struct AutoNumTab {
SelectAutonumW *widget = nullptr;
QHash<QString, NumerotationContext> contexts;
QString prefix;
};
AutoNumTab m_autonum_conductor;
AutoNumTab m_autonum_element;
AutoNumTab m_autonum_folio;
void initAutoNumTab(AutoNumTab &tab, SelectAutonumW *w, const QString &prefix);
void loadAutoNumTab(AutoNumTab &tab, QSettings &settings);
void saveAutoNumContext(AutoNumTab &tab);
void removeAutoNumContext(AutoNumTab &tab);
void persistAutonumSettings();
static bool isPlaceholder(QComboBox *combo, const QString &name);
};
/**
@@ -23,6 +23,7 @@
#include "../../utils/qetsettings.h"
#include "../../utils/qetutils.h"
#include "../../qetmessagebox.h"
#include "../nokde/kcolorbutton.h"
#include <QFileDialog>
#include <QFontDialog>
#include <QSettings>
@@ -74,6 +75,12 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->DiagramEditor_Grid_PointSize_min_sb->setValue(settings.value("diagrameditor/grid_pointsize_min", 1).toInt());
ui->DiagramEditor_Grid_PointSize_max_sb->setValue(settings.value("diagrameditor/grid_pointsize_max", 1).toInt());
ui->m_use_system_color_cb->setChecked(settings.value("usesystemcolors", "true").toBool());
bool sysColors = ui->m_use_system_color_cb->isChecked();
ui->m_custom_app_color_kpb->setEnabled(!sysColors);
if (settings.contains("customapplicationcolor"))
ui->m_custom_app_color_kpb->setColor(QColor(settings.value("customapplicationcolor").toString()));
else
ui->m_custom_app_color_kpb->setColor(QApplication::palette().color(QPalette::Window));
bool tabbed = settings.value("diagrameditor/viewmode", "tabbed") == "tabbed";
if(tabbed)
ui->m_use_tab_mode_rb->setChecked(true);
@@ -82,6 +89,25 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->m_zoom_out_beyond_folio->setChecked(settings.value("diagrameditor/zoom-out-beyond-of-folio", 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());
#ifdef QET_HAS_SCRIPTING
if (QetSettings::scriptingForcedByEnvironment()) {
//QET_ENABLE_SCRIPTING wins over the stored value, so let the box
//say so rather than offer a tick that changes nothing.
ui->m_enable_scripting->setEnabled(false);
ui->m_enable_scripting->setToolTip(
tr("Activé par la variable d'environnement "
"QET_ENABLE_SCRIPTING ; ce réglage est sans effet "
"tant qu'elle est définie."));
}
#else
//Built without Qt Qml: there is no scripting to allow. Disabled as
//well as hidden, so applyConf() leaves the stored value alone --
//a hidden box still reports its state, and writing it here would
//quietly clear a preference set on a build that does have Qml.
ui->m_enable_scripting->setVisible(false);
ui->m_enable_scripting->setEnabled(false);
#endif
ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool());
ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool());
ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt());
@@ -206,7 +232,17 @@ void GeneralConfigurationPage::applyConf()
bool must_use_system_colors = ui->m_use_system_color_cb->isChecked();
settings.setValue("usesystemcolors", must_use_system_colors);
if (was_using_system_colors != must_use_system_colors) {
QETApp::instance()->useSystemPalette(must_use_system_colors);
if (must_use_system_colors) {
QETApp::instance()->useSystemPalette(true);
} else {
QColor custom_color = ui->m_custom_app_color_kpb->color();
settings.setValue("customapplicationcolor", custom_color.name());
QETApp::instance()->useCustomPalette(custom_color);
}
} else if (!must_use_system_colors) {
QColor custom_color = ui->m_custom_app_color_kpb->color();
settings.setValue("customapplicationcolor", custom_color.name());
QETApp::instance()->useCustomPalette(custom_color);
}
settings.setValue("border-columns_0",ui->m_border_0->isChecked());
settings.setValue("lang", ui->m_lang_cb->itemData(ui->m_lang_cb->currentIndex()).toString());
@@ -227,6 +263,14 @@ void GeneralConfigurationPage::applyConf()
//DIAGRAM COMMAND
settings.setValue("diagramcommands/erase-label-on-copy", ui->m_save_label_paste->isChecked());
//SCRIPTING
//Left alone while the environment forces it on: the box is disabled
//in that case and writing its state would silently clear the user's
//real preference the first time this dialog is accepted.
if (ui->m_enable_scripting->isEnabled()) {
QetSettings::setScriptingEnabled(ui->m_enable_scripting->isChecked());
}
//GENERIC PANEL
settings.setValue("genericpanel/folio",ui->m_use_folio_label->isChecked());
@@ -625,3 +669,14 @@ void GeneralConfigurationPage::on_m_hdpi_round_cb_clicked(bool checked)
ui->m_hdpi_round_policy_cb->setEnabled(checked);
}
/**
@brief GeneralConfigurationPage::on_m_use_system_color_cb_toggled
Enable/disable the custom color picker when the system color
checkbox is toggled.
@param checked
*/
void GeneralConfigurationPage::on_m_use_system_color_cb_toggled(bool checked)
{
ui->m_custom_app_color_kpb->setEnabled(!checked);
}
@@ -53,6 +53,7 @@ class GeneralConfigurationPage : public ConfigPage
void on_ElementEditor_Grid_PointSize_min_sb_valueChanged(int value);
void on_m_hdpi_round_cb_clicked(bool checked);
void on_m_use_system_color_cb_toggled(bool checked);
private:
void fillLang();
@@ -23,15 +23,26 @@
<attribute name="title">
<string>Apparence</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="m_use_system_color_cb">
<property name="text">
<string>Utiliser les couleurs du système</string>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="m_system_color_layout">
<item>
<widget class="QCheckBox" name="m_use_system_color_cb">
<property name="text">
<string>Utiliser les couleurs du système</string>
</property>
</widget>
</item>
<item>
<widget class="KColorButton" name="m_custom_app_color_kpb">
<property name="toolTip">
<string>Couleur de l'application</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
@@ -207,6 +218,16 @@
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="m_enable_scripting">
<property name="text">
<string>Autoriser l'exécution de scripts JavaScript (Projet &gt; Exécuter un script, et --run)</string>
</property>
<property name="toolTip">
<string>Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. Désactivé par défaut ; n'exécutez que des scripts dont vous connaissez l'origine.</string>
</property>
</widget>
</item>
<item row="5" column="0">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
@@ -1161,4 +1182,11 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
</tabstops>
<resources/>
<connections/>
<customwidgets>
<customwidget>
<class>KColorButton</class>
<extends>QPushButton</extends>
<header>nokde/kcolorbutton.h</header>
</customwidget>
</customwidgets>
</ui>
@@ -381,5 +381,5 @@ QString ShortcutsConfigPage::title() const
QIcon ShortcutsConfigPage::icon() const
{
return QET::Icons::ConfigureToolbars;
return QET::Icons::ConfigureShortcuts;
}
+242
View File
@@ -0,0 +1,242 @@
/*
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 "diagrambgcolorbutton.h"
#include "../diagram.h"
#include "../diagramview.h"
#include "../palettegraphicsview.h"
#include "../qetdiagrameditor.h"
#include "../projectview.h"
#include "../qetproject.h"
#include <QApplication>
#include <QColorDialog>
#include <QMenu>
#include <QPainter>
#include <QPixmap>
namespace {
struct NamedColor { const char *context_name; QColor color; };
QList<NamedColor> standardColors()
{
return {
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Blanc"), QColor(0xFF, 0xFF, 0xFF)},
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Blanc cassé"), QColor(0xFD, 0xFB, 0xF5)},
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Gris clair"), QColor(0xE0, 0xE0, 0xE0)},
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Gris"), QColor(0x80, 0x80, 0x80)},
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Gris foncé"), QColor(0x40, 0x40, 0x40)},
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Noir"), QColor(0x00, 0x00, 0x00)},
};
}
const int MAX_RECENT = 6;
}
/**
@brief DiagramBgColorToolButton::DiagramBgColorToolButton
@param editor : the diagram editor this button acts on
@param parent
*/
DiagramBgColorToolButton::DiagramBgColorToolButton(QETDiagramEditor *editor, QWidget *parent) :
QToolButton(parent),
m_editor(editor)
{
setPopupMode(QToolButton::InstantPopup);
setToolTip(tr("Couleur de fond du folio"));
setStatusTip(tr("Choisir la couleur de fond du folio",
"status bar tip"));
m_is_system_color = true;
m_current = QApplication::palette().color(QPalette::Base);
setMenu(new QMenu(this));
rebuildMenu();
setSwatch(m_current);
}
/**
@brief DiagramBgColorToolButton::rebuildMenu
*/
void DiagramBgColorToolButton::rebuildMenu()
{
QMenu *m = menu();
m->clear();
QAction *sys = m->addAction(tr("Couleur système"));
connect(sys, &QAction::triggered, this, &DiagramBgColorToolButton::applySystemColor);
m->addSeparator();
for (const auto &nc : standardColors())
{
const QColor c = nc.color;
QAction *a = m->addAction(swatchIcon(c),
tr(nc.context_name));
connect(a, &QAction::triggered, this, [this, c]() { applyColor(c); });
}
if (!m_recent.isEmpty())
{
m->addSeparator();
QAction *title = m->addAction(tr("Récemment utilisées"));
title->setEnabled(false);
for (const QColor &c : std::as_const(m_recent))
{
QAction *a = m->addAction(swatchIcon(c), c.name());
connect(a, &QAction::triggered, this, [this, c]() { applyColor(c); });
}
}
m->addSeparator();
QAction *other = m->addAction(tr("Autre couleur…"));
connect(other, &QAction::triggered, this, &DiagramBgColorToolButton::chooseOtherColor);
}
/**
@brief DiagramBgColorToolButton::applyColor
Set a custom background color on all open diagrams.
@param color
*/
void DiagramBgColorToolButton::applyColor(const QColor &color)
{
if (!color.isValid()) {
return;
}
m_is_system_color = false;
rememberRecent(color);
setSwatch(color);
PaletteGraphicsView::setCustomBackgroundColor(true);
Diagram::background_color = color;
QETDiagramEditor *editor = m_editor;
if (!editor) {
return;
}
for (ProjectView *pv : editor->openedProjects())
for (Diagram *d : pv->project()->diagrams())
d->update();
}
/**
@brief DiagramBgColorToolButton::applySystemColor
Restore the system-default background and re-enable dark-mode
inversion.
*/
void DiagramBgColorToolButton::applySystemColor()
{
m_is_system_color = true;
m_current = QApplication::palette().color(QPalette::Base);
setSwatch(m_current);
PaletteGraphicsView::setCustomBackgroundColor(false);
Diagram::background_color = Qt::white;
QETDiagramEditor *editor = m_editor;
if (!editor) {
return;
}
for (ProjectView *pv : editor->openedProjects())
for (Diagram *d : pv->project()->diagrams())
d->update();
}
/**
@brief DiagramBgColorToolButton::chooseOtherColor
*/
void DiagramBgColorToolButton::chooseOtherColor()
{
const QColor c = QColorDialog::getColor(m_current, this,
tr("Choisir une couleur de fond"));
if (c.isValid()) {
applyColor(c);
}
}
/**
@brief DiagramBgColorToolButton::rememberRecent
Most recent first, no duplicates, capped.
@param color
*/
void DiagramBgColorToolButton::rememberRecent(const QColor &color)
{
for (const auto &nc : standardColors()) {
if (nc.color == color) {
return;
}
}
m_recent.removeAll(color);
m_recent.prepend(color);
while (m_recent.size() > MAX_RECENT) {
m_recent.removeLast();
}
rebuildMenu();
}
/**
@brief DiagramBgColorToolButton::setSwatch
@param color
*/
void DiagramBgColorToolButton::setSwatch(const QColor &color)
{
m_current = color;
setIcon(swatchIcon(color));
}
/**
@brief DiagramBgColorToolButton::syncFromDiagram
Sync the button swatch to whatever Diagram::background_color is
currently set to. Called when the active folio changes.
*/
void DiagramBgColorToolButton::syncFromDiagram()
{
if (m_is_system_color) {
m_current = QApplication::palette().color(QPalette::Base);
} else {
m_current = Diagram::background_color;
}
setSwatch(m_current);
}
/**
@brief DiagramBgColorToolButton::updateEnabledState
*/
void DiagramBgColorToolButton::updateEnabledState()
{
setEnabled(m_editor && m_editor->currentProjectView());
}
/**
@brief DiagramBgColorToolButton::swatchIcon
@param color
@return a plain square of that colour, outlined so that white and very
light colours are still visible against the toolbar.
*/
QIcon DiagramBgColorToolButton::swatchIcon(const QColor &color)
{
QPixmap pix(16, 16);
pix.fill(Qt::transparent);
QPainter p(&pix);
p.setRenderHint(QPainter::Antialiasing, false);
p.setBrush(color);
p.setPen(QPen(QColor(0x40, 0x40, 0x40), 1));
p.drawRect(0, 0, 15, 15);
p.end();
return QIcon(pix);
}
+64
View File
@@ -0,0 +1,64 @@
/*
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 DIAGRAMBGCOLORTOOLBUTTON_H
#define DIAGRAMBGCOLORTOOLBUTTON_H
#include <QColor>
#include <QList>
#include <QToolButton>
class QETDiagramEditor;
/**
@brief The DiagramBgColorToolButton class
Color picker for the diagram sheet background, placed in the
"Affichage" toolbar. Mirrors the ConductorColorToolButton UX:
preset colors in the dropdown, "Autre couleur..." at the bottom,
and a swatch icon on the button itself.
Picking "Couleur système" clears any custom colour and lets the
dark-mode inversion handle the background as before.
*/
class DiagramBgColorToolButton : public QToolButton
{
Q_OBJECT
public:
explicit DiagramBgColorToolButton(QETDiagramEditor *editor,
QWidget *parent = nullptr);
public slots:
void updateEnabledState();
void syncFromDiagram();
private:
void rebuildMenu();
void applyColor(const QColor &color);
void applySystemColor();
void chooseOtherColor();
void rememberRecent(const QColor &color);
void setSwatch(const QColor &color);
static QIcon swatchIcon(const QColor &color);
QETDiagramEditor *m_editor = nullptr;
QList<QColor> m_recent;
QColor m_current;
bool m_is_system_color = false;
};
#endif // DIAGRAMBGCOLORTOOLBUTTON_H
+114
View File
@@ -0,0 +1,114 @@
/*
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 "duplicateoffsetdialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QSettings>
#include <QSpinBox>
#include <QVBoxLayout>
namespace {
// Stored as one integer per axis, in grid steps -- not pixels, so the
// remembered offset still makes sense if the grid size ever changes.
const QString kOffsetXKey = QStringLiteral("diagrameditor/duplicate_offset_x");
const QString kOffsetYKey = QStringLiteral("diagrameditor/duplicate_offset_y");
}
DuplicateOffsetDialog::DuplicateOffsetDialog(QWidget *parent) :
QDialog(parent)
{
setWindowTitle(tr("Dupliquer"));
auto *form = new QFormLayout;
m_spacing = new QSpinBox(this);
m_spacing->setRange(1, 1000);
m_spacing->setSuffix(tr(" pas de grille"));
form->addRow(tr("Espacement :"), m_spacing);
m_direction = new QComboBox(this);
// Order matches the Direction enum, so currentIndex() can be used
// directly wherever Direction is needed.
m_direction->addItem(tr("Haut"));
m_direction->addItem(tr("Bas"));
m_direction->addItem(tr("Gauche"));
m_direction->addItem(tr("Droite"));
form->addRow(tr("Direction :"), m_direction);
const QPoint saved = savedStepOffset();
// The saved value is a signed (dx, dy) pair, not itself a
// spacing+direction pair, so it has to be decomposed back into the
// two the dialog shows. Exactly one axis is ever non-zero (see
// stepOffset()), so whichever one is picks the direction; a value
// that somehow has neither (only possible if QSettings was hand-
// edited) falls back to the same default stepOffset() would.
int spacing = 1;
Direction direction = Right;
if (saved.x() > 0) { direction = Right; spacing = saved.x(); }
else if (saved.x() < 0) { direction = Left; spacing = -saved.x(); }
else if (saved.y() > 0) { direction = Down; spacing = saved.y(); }
else if (saved.y() < 0) { direction = Up; spacing = -saved.y(); }
m_spacing->setValue(spacing);
m_direction->setCurrentIndex(static_cast<int>(direction));
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addLayout(form);
layout->addWidget(buttons);
}
QPoint DuplicateOffsetDialog::stepOffset() const
{
const int spacing = m_spacing->value();
switch (static_cast<Direction>(m_direction->currentIndex())) {
case Up: return QPoint(0, -spacing);
case Down: return QPoint(0, spacing);
case Left: return QPoint(-spacing, 0);
case Right: return QPoint(spacing, 0);
}
return QPoint(spacing, 0); // unreachable; keeps -Wreturn-type quiet
}
QPoint DuplicateOffsetDialog::savedStepOffset()
{
QSettings settings;
if (!hasSavedStepOffset()) {
return QPoint(1, 0); // default: one grid step to the right
}
return QPoint(settings.value(kOffsetXKey).toInt(),
settings.value(kOffsetYKey).toInt());
}
void DuplicateOffsetDialog::saveStepOffset(const QPoint &steps)
{
QSettings settings;
settings.setValue(kOffsetXKey, steps.x());
settings.setValue(kOffsetYKey, steps.y());
}
bool DuplicateOffsetDialog::hasSavedStepOffset()
{
QSettings settings;
return settings.contains(kOffsetXKey) && settings.contains(kOffsetYKey);
}
+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 DUPLICATEOFFSETDIALOG_H
#define DUPLICATEOFFSETDIALOG_H
#include <QDialog>
#include <QPoint>
class QSpinBox;
class QComboBox;
/**
@brief The DuplicateOffsetDialog class
Asks how far, and in which of the four cardinal directions, Ctrl+D
(DiagramView::duplicate(), bugtracker #991) should offset a copy from
its source. Shown once, then remembered: the answer is stored in
QSettings and reused by every later Ctrl+D press without asking
again, until this dialog is reopened deliberately.
*/
class DuplicateOffsetDialog : public QDialog
{
Q_OBJECT
public:
enum Direction { Up, Down, Left, Right };
explicit DuplicateOffsetDialog(QWidget *parent = nullptr);
/// The offset in grid steps, positive along X to the right
/// and positive along Y downward -- QET's own scene axes,
/// matching the sign convention setPos() already uses
/// everywhere else in this codebase.
QPoint stepOffset() const;
/// Reads the last-confirmed spacing/direction from QSettings,
/// or the default (1 step, right) if none was ever set.
static QPoint savedStepOffset();
/// Writes @p steps to QSettings, in the same X/Y convention
/// as stepOffset().
static void saveStepOffset(const QPoint &steps);
/// Whether a direction/spacing has already been confirmed
/// once, i.e. whether Ctrl+D can skip the dialog.
static bool hasSavedStepOffset();
private:
QSpinBox *m_spacing = nullptr;
QComboBox *m_direction = nullptr;
};
#endif // DUPLICATEOFFSETDIALOG_H
+63 -21
View File
@@ -314,31 +314,15 @@ void LinkElementCommand::redo()
if(m_element->diagram()) m_element->diagram()->showMe();
makeLink(m_linked_after);
//If the action is to link two reports together, we check if the conductors
//of the new potential have the same text, function, and protocol.
//if not, a dialog ask what do to.
//If the action is to link two reports together, and the conductors
//of the new potential disagree on a property that matters, a
//dialog asks what to do. See reportLinkNeedsPotentialChoice() for
//what "disagree" checks and the bug fixed there (bugtracker #974).
if (m_first_redo && (m_element->linkType() & Element::AllReport) \
&& m_element->conductors().size() \
&& m_linked_after.size() && m_linked_after.first()->conductors().size())
{
//fill list of potential
QSet <Conductor *> c_list = m_element->conductors().first()->relatedPotentialConductors();
c_list << m_element->conductors().first();
//fill list of text
QStringList str_txt;
QStringList str_funct;
QStringList str_tens;
for (const Conductor *c : c_list)
{
str_txt << c->properties().text;
str_funct << c->properties().m_function;
str_tens << c->properties().m_tension_protocol;
str_tens << c->properties().m_wire_color;
str_tens << c->properties().m_wire_section;
}
//check text list, isn't same in potential, ask user what to do
if (!QET::eachStrIsEqual(str_txt) || !QET::eachStrIsEqual(str_funct) || !QET::eachStrIsEqual(str_tens))
if (reportLinkNeedsPotentialChoice(m_element, m_linked_after.first()))
{
PotentialSelectorDialog psd(m_element, this);
psd.exec();
@@ -348,6 +332,64 @@ void LinkElementCommand::redo()
QUndoCommand::redo();
}
/**
@brief LinkElementCommand::reportLinkNeedsPotentialChoice
Whether linking these two report elements (next_report/previous_report)
would pop PotentialSelectorDialog -- i.e. whether their conductors (if
any exist yet, on either side) disagree on a property redo() cares
about. Exposed as its own static method, rather than left inline in
redo(), for the same reason ConductorCreator::needsPotentialChoice()
is: a caller with nobody there to answer a modal dialog (the scripting
API) can check first and decline, and the condition cannot drift away
from the one redo() actually applies.
Bug fixed here (bugtracker #974): the original check built ONE
combined list from three unrelated fields (tension_protocol,
wire_color, wire_section) and tested that whole list for equality --
comparing a tension-protocol string against a wire-colour string is
never equal even when each field individually matches across every
conductor, and wire_color/wire_section are ConductorProperties::
m_wire_color/m_wire_section, a separate free-text documentation pair
that says nothing about how the wire is actually drawn (that is
"color"/"style"). Net effect: the dialog could not reliably detect a
real mismatch, including the exact case #974 reported -- two
report-linked conductors drawn in different colours -- and could just
as easily fire on conductors that matched in every way that mattered.
Comparing each relevant field (text/num, function, tension protocol,
colour, line style) on its own fixes both.
@param element_a @param element_b the two elements about to be (or
already) linked; order does not matter
@return true if the dialog would (or does) open
*/
bool LinkElementCommand::reportLinkNeedsPotentialChoice(Element *element_a, Element *element_b)
{
if (!element_a || !element_b) return false;
if (element_a->conductors().isEmpty() || element_b->conductors().isEmpty()) return false;
QSet<Conductor *> c_list;
for (Element *e : {element_a, element_b})
{
if (e->conductors().isEmpty()) continue;
c_list << e->conductors().first();
c_list += e->conductors().first()->relatedPotentialConductors();
}
if (c_list.size() < 2) return false;
QStringList str_txt, str_funct, str_tens, str_color, str_style;
for (const Conductor *c : std::as_const(c_list))
{
str_txt << c->properties().text;
str_funct << c->properties().m_function;
str_tens << c->properties().m_tension_protocol;
str_color << c->properties().color.name();
str_style << QString::number(int(c->properties().style));
}
return !QET::eachStrIsEqual(str_txt) || !QET::eachStrIsEqual(str_funct)
|| !QET::eachStrIsEqual(str_tens) || !QET::eachStrIsEqual(str_color)
|| !QET::eachStrIsEqual(str_style);
}
/**
@brief LinkElementCommand::setUpNewLink
Update the content of m_link_after with the content of element_list.
+1
View File
@@ -38,6 +38,7 @@ class LinkElementCommand : public QUndoCommand
bool mergeWith(const QUndoCommand *other) override;
static bool isLinkable (Element *element_a, Element *element_b, bool already_linked = false);
static bool reportLinkNeedsPotentialChoice(Element *element_a, Element *element_b);
void setLink (const QList<Element *>& element_list);
void setLink (Element *element_);
+7 -16
View File
@@ -55,21 +55,13 @@ m_diagram(diagram)
{
openDialog();
QString text;
if(texts_list.count())
text.append(QObject::tr("Pivoter %1 textes").arg(texts_list.count()));
if(groups_list.count())
{
if(text.isEmpty())
text.append(QObject::tr("Pivoter"));
else
text.append(QObject::tr(" et"));
text.append(QObject::tr(" %1 groupes de textes").arg(groups_list.count()));
}
if(!text.isNull())
setText(text);
QStringList parts;
if (texts_list.count())
parts << QObject::tr("%n texte(s)", "", texts_list.count());
if (groups_list.count())
parts << QObject::tr("%n groupe(s) de textes", "", groups_list.count());
setText(QObject::tr("Pivoter %1").arg(QLocale(QETApp::interfaceLanguage()).createSeparatedList(parts)));
for(DiagramTextItem *dti : texts_list)
setupAnimation(dti, "rotation", dti->rotation(), m_rotation);
for(ElementTextItemGroup *grp : groups_list)
@@ -77,7 +69,6 @@ m_diagram(diagram)
}
else
setObsolete(true);
}
void RotateTextsCommand::undo()
+40 -5
View File
@@ -18,7 +18,9 @@
#include "conductorcreator.h"
#include "../conductorautonumerotation.h"
#include "../dataBase/projectdatabase.h"
#include "../diagram.h"
#include "../qetproject.h"
#include "../undocommand/addgraphicsobjectcommand.h"
#include "../qetgraphicsitem/conductor.h"
#include "../qetgraphicsitem/element.h"
@@ -68,6 +70,15 @@ ConductorCreator::ConductorCreator(Diagram *d, QList<Terminal *> terminals_list)
for(Conductor *c : c_list) {
c->refreshText();
//refreshText() resolves an auto-numbering formula into
//properties.text without emitting propertiesChange, which is
//what the project database listens to. The row was inserted
//while text was still the raw formula ("W%sequ_1"), so without
//this the wiring list and BOM read the formula, not "W1",
//until something forces a full rebuild.
if (d->project() && d->project()->dataBase()) {
d->project()->dataBase()->updateConductor(c);
}
}
}
@@ -95,6 +106,29 @@ void ConductorCreator::create(Diagram *d, const QPolygonF &polygon)
}
}
/**
@brief ConductorCreator::needsPotentialChoice
Whether creating a potential between these terminals would ask the user
to choose which of several existing potentials to inherit from -- that
is, whether the constructor would reach PotentialSelectorDialog.
This exists for callers with nobody there to answer: the dialog is a
plain QDialog::exec(), not routed through QET::QetMessageBox, so its
non-interactive mode does not cover it and a headless caller would hang
on it indefinitely. Such a caller can check this first and decline.
Exposed here, rather than reimplemented by the caller, so the condition
cannot drift away from the one setUpPropertieToUse() actually applies.
@param terminals_list the terminals a potential would be created between
@return true if the constructor would open the dialog
*/
bool ConductorCreator::needsPotentialChoice(const QList<Terminal *> &terminals_list)
{
if (terminals_list.size() <= 1) {
return false;
}
return existingPotential(terminals_list).size() >= 2;
}
/**
@brief ConductorCreator::propertieToUse
@return true if the caller should proceed with conductor creation,
@@ -104,7 +138,7 @@ void ConductorCreator::create(Diagram *d, const QPolygonF &polygon)
*/
bool ConductorCreator::setUpPropertieToUse()
{
QList<Conductor *> potentials = existingPotential();
QList<Conductor *> potentials = existingPotential(m_terminals_list);
//There is an existing potential
//we get one of them
@@ -145,14 +179,15 @@ bool ConductorCreator::setUpPropertieToUse()
@brief ConductorCreator::existingPotential
Return the list of existing potential of
the terminal list
@param terminals_list the terminals to inspect
@return c_list QList<Conductor *>
*/
QList<Conductor *> ConductorCreator::existingPotential()
QList<Conductor *> ConductorCreator::existingPotential(const QList<Terminal *> &terminals_list)
{
QList<Conductor *> c_list;
QList<Terminal *> t_exclude;
for (Terminal *t : m_terminals_list)
for (Terminal *t : terminals_list)
{
if (t_exclude.contains(t)) {
continue;
@@ -166,9 +201,9 @@ QList<Conductor *> ConductorCreator::existingPotential()
//in the same potential of c, and if true, exclude this terminal from the search.
for (Conductor *c : t->conductors().first()->relatedPotentialConductors(false))
{
if (m_terminals_list.contains(c->terminal1)) {
if (terminals_list.contains(c->terminal1)) {
t_exclude.append(c->terminal1);
} else if (m_terminals_list.contains(c->terminal2)) {
} else if (terminals_list.contains(c->terminal2)) {
t_exclude.append(c->terminal2);
}
}
+3 -2
View File
@@ -38,10 +38,11 @@ class ConductorCreator
public:
ConductorCreator(Diagram *d, QList<Terminal *> terminals_list);
static void create(Diagram *d, const QPolygonF &polygon);
static bool needsPotentialChoice(const QList<Terminal *> &terminals_list);
private:
static QList<Conductor *> existingPotential(const QList<Terminal *> &terminals_list);
bool setUpPropertieToUse();
QList<Conductor *> existingPotential();
Terminal *hubTerminal();
+47
View File
@@ -19,6 +19,7 @@
#include "qetsettings.h"
#include <QSettings>
#include <QVariant>
#include <QByteArray>
namespace QetSettings
{
@@ -105,4 +106,50 @@ namespace QetSettings
return default_policy;
}
}
/**
* @brief scriptingForcedByEnvironment
* @return true if QET_ENABLE_SCRIPTING is set to 1 in the environment.
*
* The way to turn scripting on where there is nobody to tick a box:
* a headless run, a CI job, a build server. Those have no settings
* file worth writing to -- and on a machine whose HOME is created
* fresh for the run, writing one would not survive anyway.
*/
bool scriptingForcedByEnvironment()
{
return qgetenv("QET_ENABLE_SCRIPTING") == QByteArray("1");
}
/**
* @brief scriptingEnabled
* @return whether QElectroTech may run a JavaScript script.
*
* Off unless the user turned it on. A script reaches the whole project
* and the filesystem through the export calls, so it is capability the
* great majority of users never asked for; leaving it on by default
* would hand it to them anyway. @sa setScriptingEnabled
*
* The environment override wins over the stored value, and is checked
* first so that a machine with no settings at all still answers.
*/
bool scriptingEnabled()
{
if (scriptingForcedByEnvironment()) {
return true;
}
QSettings settings;
return settings.value("scripting/enabled", false).toBool();
}
/**
* @brief setScriptingEnabled
* Store whether scripting is allowed. @sa scriptingEnabled
* @param enabled
*/
void setScriptingEnabled(bool enabled)
{
QSettings settings;
settings.setValue("scripting/enabled", enabled);
}
}
+4
View File
@@ -32,6 +32,10 @@ namespace QetSettings
void setHdpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy policy);
Qt::HighDpiScaleFactorRoundingPolicy hdpiScaleFactorRoundingPolicy(
Qt::HighDpiScaleFactorRoundingPolicy default_policy = Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
bool scriptingEnabled();
void setScriptingEnabled(bool enabled);
bool scriptingForcedByEnvironment();
}
#endif // QETSETTINGS_H
-4
View File
@@ -25,10 +25,6 @@ message(". PROJECT_SOURCE_DIR :" ${PROJECT_SOURCE_DIR})
# Add sub directories
message(". Add sub directory catch")
add_subdirectory(catch)
message(". Add sub directory googletest")
add_subdirectory(googletest)
message(". Add sub directory googlemock")
add_subdirectory(googlemock)
message(". Add sub directory modal-quit-regression")
add_subdirectory(modal-quit-regression)
message(". Add sub directory qttest")
-67
View File
@@ -1,67 +0,0 @@
---
BasedOnStyle: LLVM
AlignAfterOpenBracket: AlwaysBreak
AlignConsecutiveMacros: 'true'
AlignConsecutiveAssignments: 'true'
AlignConsecutiveDeclarations: 'true'
AlignEscapedNewlines: Right
AlignOperands: 'true'
AlignTrailingComments: 'true'
AllowAllArgumentsOnNextLine: 'false'
AllowAllConstructorInitializersOnNextLine: 'true'
AllowAllParametersOfDeclarationOnNextLine: 'true'
AllowShortBlocksOnASingleLine: 'true'
AllowShortCaseLabelsOnASingleLine: 'true'
AllowShortFunctionsOnASingleLine: All
AllowShortIfStatementsOnASingleLine: Always
AllowShortLambdasOnASingleLine: All
AllowShortLoopsOnASingleLine: 'true'
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: 'true'
AlwaysBreakTemplateDeclarations: 'Yes'
BinPackArguments: 'false'
BinPackParameters: 'false'
BreakAfterJavaFieldAnnotations: 'true'
BreakBeforeBinaryOperators: NonAssignment
BreakBeforeBraces: Allman
BreakBeforeTernaryOperators: 'false'
BreakConstructorInitializers: AfterColon
BreakInheritanceList: AfterColon
BreakStringLiterals: 'true'
ColumnLimit: '80'
CompactNamespaces: 'false'
ConstructorInitializerAllOnOneLineOrOnePerLine: 'true'
Cpp11BracedListStyle: 'true'
FixNamespaceComments: 'true'
IncludeBlocks: Regroup
IndentCaseLabels: 'false'
IndentPPDirectives: AfterHash
IndentWidth: '4'
JavaScriptWrapImports: 'true'
Language: Cpp
MaxEmptyLinesToKeep: '1'
NamespaceIndentation: All
PointerAlignment: Left
ReflowComments: 'true'
SortIncludes: 'true'
SortUsingDeclarations: 'true'
SpaceAfterCStyleCast: 'true'
SpaceAfterLogicalNot: 'true'
SpaceAfterTemplateKeyword: 'true'
SpaceBeforeAssignmentOperators: 'true'
SpaceBeforeCpp11BracedList: 'true'
SpaceBeforeCtorInitializerColon: 'true'
SpaceBeforeInheritanceColon: 'true'
SpaceBeforeParens: ControlStatements
SpaceBeforeRangeBasedForLoopColon: 'true'
SpaceInEmptyParentheses: 'false'
SpacesInAngles: 'false'
SpacesInCStyleCastParentheses: 'false'
SpacesInContainerLiterals: 'false'
SpacesInParentheses: 'false'
SpacesInSquareBrackets: 'false'
Standard: Cpp11
TabWidth: '4'
UseTab: Always
...
-73
View File
@@ -1,73 +0,0 @@
# This file is used to ignore files which are generated
# ----------------------------------------------------------------------------
*~
*.autosave
*.a
*.core
*.moc
*.o
*.obj
*.orig
*.rej
*.so
*.so.*
*_pch.h.cpp
*_resource.rc
*.qm
.#*
*.*#
core
!core/
tags
.DS_Store
.directory
*.debug
Makefile*
*.prl
*.app
moc_*.cpp
ui_*.h
qrc_*.cpp
Thumbs.db
*.res
*.rc
/.qmake.cache
/.qmake.stash
# qtcreator generated files
*.pro.user*
# xemacs temporary files
*.flc
# Vim temporary files
.*.swp
# Visual Studio generated files
*.ib_pdb_index
*.idb
*.ilk
*.pdb
*.sln
*.suo
*.vcproj
*vcproj.*.*.user
*.ncb
*.sdf
*.opensdf
*.vcxproj
*vcxproj.*
# MinGW generated files
*.Debug
*.Release
# Python byte code
*.pyc
# Binaries
# --------
*.dll
*.exe
-78
View File
@@ -1,78 +0,0 @@
# Copyright 2006 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/>.
cmake_minimum_required(VERSION 3.5)
message("..___________________________________________________________________")
project(G_unitmocktests LANGUAGES CXX)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
SET(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
message(".. PROJECT_NAME :" ${PROJECT_NAME})
message(".. PROJECT_SOURCE_DIR :" ${PROJECT_SOURCE_DIR})
if(NOT DEFINED QET_DIR)
set(QET_DIR "../..")
message(".. QET_DIR is not set, assuming QET is ../..")
endif()
message(".. QET_DIR :" ${QET_DIR})
if(NOT DEFINED QET_COMPONENTS)
message(".. QET_COMPONENTS is not set !!! I set them up !!!")
include(../../cmake/qet_compilation_vars.cmake)
endif()
find_package(
Qt6
COMPONENTS
${QET_COMPONENTS}
REQUIRED)
Include(FetchContent)
FetchContent_Declare(
GTest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.10.0)
set(INSTALL_GTEST OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(GTest)
include(../../cmake/fetch_kdeaddons.cmake)
include(../../cmake/fetch_singleapplication.cmake)
include(../../cmake/fetch_pugixml.cmake)
enable_testing()
add_executable(
${PROJECT_NAME}
tst_My_test.cpp
main.cpp
)
target_link_libraries(
${PROJECT_NAME}
PUBLIC
gmock gmock_main
PRIVATE
${KF_PRIVATE_LIBRARIES}
${QET_PRIVATE_LIBRARIES})
-15
View File
@@ -1,15 +0,0 @@
#include <QtGui/QGuiApplication>
#include <gmock/gmock.h>
int main(int argc, char** argv)
{
QGuiApplication app(argc, argv);
// disable the whole debug output (we want only the output from gtest)
// Debug::instance()->setDebugLevelLogFile(Debug::DebugLevel_t::Nothing);
// Debug::instance()->setDebugLevelStderr(Debug::DebugLevel_t::Nothing);
// init gtest and run all tests
::testing::InitGoogleMock(&argc, argv);
return RUN_ALL_TESTS();
}
-5
View File
@@ -1,5 +0,0 @@
#include "../../sources/borderproperties.h"
#include <gmock/gmock.h>
TEST(googlemocktest, sample_mock) { EXPECT_EQ(1, 1); }
-67
View File
@@ -1,67 +0,0 @@
---
BasedOnStyle: LLVM
AlignAfterOpenBracket: AlwaysBreak
AlignConsecutiveMacros: 'true'
AlignConsecutiveAssignments: 'true'
AlignConsecutiveDeclarations: 'true'
AlignEscapedNewlines: Right
AlignOperands: 'true'
AlignTrailingComments: 'true'
AllowAllArgumentsOnNextLine: 'false'
AllowAllConstructorInitializersOnNextLine: 'true'
AllowAllParametersOfDeclarationOnNextLine: 'true'
AllowShortBlocksOnASingleLine: 'true'
AllowShortCaseLabelsOnASingleLine: 'true'
AllowShortFunctionsOnASingleLine: All
AllowShortIfStatementsOnASingleLine: Always
AllowShortLambdasOnASingleLine: All
AllowShortLoopsOnASingleLine: 'true'
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: 'true'
AlwaysBreakTemplateDeclarations: 'Yes'
BinPackArguments: 'false'
BinPackParameters: 'false'
BreakAfterJavaFieldAnnotations: 'true'
BreakBeforeBinaryOperators: NonAssignment
BreakBeforeBraces: Allman
BreakBeforeTernaryOperators: 'false'
BreakConstructorInitializers: AfterColon
BreakInheritanceList: AfterColon
BreakStringLiterals: 'true'
ColumnLimit: '80'
CompactNamespaces: 'false'
ConstructorInitializerAllOnOneLineOrOnePerLine: 'true'
Cpp11BracedListStyle: 'true'
FixNamespaceComments: 'true'
IncludeBlocks: Regroup
IndentCaseLabels: 'false'
IndentPPDirectives: AfterHash
IndentWidth: '4'
JavaScriptWrapImports: 'true'
Language: Cpp
MaxEmptyLinesToKeep: '1'
NamespaceIndentation: All
PointerAlignment: Left
ReflowComments: 'true'
SortIncludes: 'true'
SortUsingDeclarations: 'true'
SpaceAfterCStyleCast: 'true'
SpaceAfterLogicalNot: 'true'
SpaceAfterTemplateKeyword: 'true'
SpaceBeforeAssignmentOperators: 'true'
SpaceBeforeCpp11BracedList: 'true'
SpaceBeforeCtorInitializerColon: 'true'
SpaceBeforeInheritanceColon: 'true'
SpaceBeforeParens: ControlStatements
SpaceBeforeRangeBasedForLoopColon: 'true'
SpaceInEmptyParentheses: 'false'
SpacesInAngles: 'false'
SpacesInCStyleCastParentheses: 'false'
SpacesInContainerLiterals: 'false'
SpacesInParentheses: 'false'
SpacesInSquareBrackets: 'false'
Standard: Cpp11
TabWidth: '4'
UseTab: Always
...
-73
View File
@@ -1,73 +0,0 @@
# This file is used to ignore files which are generated
# ----------------------------------------------------------------------------
*~
*.autosave
*.a
*.core
*.moc
*.o
*.obj
*.orig
*.rej
*.so
*.so.*
*_pch.h.cpp
*_resource.rc
*.qm
.#*
*.*#
core
!core/
tags
.DS_Store
.directory
*.debug
Makefile*
*.prl
*.app
moc_*.cpp
ui_*.h
qrc_*.cpp
Thumbs.db
*.res
*.rc
/.qmake.cache
/.qmake.stash
# qtcreator generated files
*.pro.user*
# xemacs temporary files
*.flc
# Vim temporary files
.*.swp
# Visual Studio generated files
*.ib_pdb_index
*.idb
*.ilk
*.pdb
*.sln
*.suo
*.vcproj
*vcproj.*.*.user
*.ncb
*.sdf
*.opensdf
*.vcxproj
*vcxproj.*
# MinGW generated files
*.Debug
*.Release
# Python byte code
*.pyc
# Binaries
# --------
*.dll
*.exe

Some files were not shown because too many files have changed in this diff Show More