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>
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>
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>
Use a bound VACUUM INTO path and remove the stale SQLite
handle declaration. Fix shell continuations in Windows CI and
Debian installation instructions.
#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>
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>
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>
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>
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>
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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
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.
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>
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>
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>
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>
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>
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>
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>
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
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.
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
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
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.
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.
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.
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
QGraphicsView::render() paints only what the scene draws, so a scene
without a background brush left the off-screen buffer uninitialized
and the inversion turned that memory into noise. The buffer is now
filled white first, which the inversion turns into the Base color.
listenToScene() connected a new receiver on every setScene() call and
never dropped the previous scene's. It now keeps a single connection
and replaces it.
Test in tst_qetpalette: paletteViewFillsWhatTheSceneLeavesBlank.
After a live light/dark switch only the sheet changed colors; the
viewport around it kept the previous palette. Qt repaints a widget on
an application palette change only when the widget's own palette
changed with it, and under the folio tab widget's style sheet it does
not, so the only repaints came from the scene and covered the scene
rectangle alone. PaletteGraphicsView now watches the application
object for ApplicationPaletteChange, the one receiver Qt always
notifies, and repaints its whole viewport.
The test paletteViewFollowsTheApplicationUnderAStyleSheet now also
requires a full-viewport repaint after each switch, before anything
asks the view for a rendering.
The folio tab widget carries a style sheet, and QStyleSheetStyle pins
the palette of every widget under it to the application palette in
force when the sheet was applied. After a live light/dark switch the
view's own palette() is therefore stale: the folio kept its dark sheet
after a switch to light, and kept its white sheet after a switch to
dark. PaletteGraphicsView now reads the application palette both for
the decision to invert and for the sheet and ink colors.
Test in tst_qetpalette: paletteViewFollowsTheApplicationUnderAStyleSheet
puts the view in a tab widget with a style sheet and switches the
application palette to dark and back.
The inverted painting, the rubber band replay and the changed()
receiver lived in DiagramView, which the unit tests cannot link, so the
update-flag regression was only covered through a stand-in view. They
now live in PaletteGraphicsView, a QGraphicsView subclass with no
other dependency, and DiagramView derives from it. The view tells a
subclass through paintingInverted(bool) when it renders for an
inverted display; DiagramView forwards that to the diagram. The grid
dot rule moves out of Diagram::drawBackground into
QET::Palette::gridDotColor().
tst_qetpalette now links the real class: gridDotColorSoftensInvertedDots,
paletteViewFollowsThePalette (light sheet, dark sheet at text contrast
with a red box still red and the paintingInverted calls in order, back
to light), paletteViewKeepsSceneUpdatesFlowing (three whole-scene
updates and a selection each repaint, scene set after construction),
paletteViewDrawsTheRubberBand.
On a dark palette the folio view paints through QGraphicsView::render()
instead of onto its viewport. In that case QGraphicsView never clears
the scene's "update everything" flag, and while the flag is set every
further QGraphicsScene::update() and item update is dropped: from the
second Diagram::update() on, the grid toggle, the white/gray toggle and
even a selection waited for an unrelated repaint. With a receiver on
QGraphicsScene::changed() the scene clears the flag before it emits, so
DiagramView now connects an empty receiver in its constructor.
While the view paints for inversion, Diagram draws the grid dots a
third of the way from the sheet color to black, so they come out as a
soft gray on the dark sheet instead of as bright as the ink. Printing
and export never take that path.
Test in tst_qetpalette: sceneUpdatesReachARenderedView.
On a dark palette the folio stayed a white sheet with black ink, and
the white/gray toggle only darkened the sheet while the ink stayed
black. DiagramView now renders each repaint into an image and inverts
its lightness before blitting it: white becomes the palette's Base,
black becomes its Text, and colored conductors and elements keep their
hue. The document, printing and export are untouched; only the screen
rendering changes, and only while the palette is dark.
QET::Palette::invertLightness does the inversion in one integer pass
(adding 255 - max - min to the three channels inverts the HSL lightness
and keeps hue and saturation), then stretches the result between the
sheet and ink colors through three lookup tables. A 4K viewport costs
about 9 ms in a release build. QGraphicsView::render() skips the
selection rubber band, so the view draws it again after the inversion.
Tests in tst_qetpalette: invertLightnessMapsSheetAndInk,
invertedViewReadsOnDarkSheet, invertLightnessSpeed.
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>
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>
Hovering a tool button changed only its frame, and on the dark palette
Fusion's hover frame is too faint to notice, so nothing told the user
which button was under the pointer (GitHub #870, PR 3 of the plan).
QETStyle wraps the running style. For QIcon::Active it returns line-art
icons tinted in the palette's highlight color, lightened until the tint
reads at 3:1 on the Light role, which is the top of Fusion's hover
gradient. Colored icons keep their colors, using the same line-art rule
as misc/make_icon_themes.py. Every other mode goes to the base style.
Fusion also asks for QIcon::Active for the icon of a highlighted menu
item and paints it on the highlight bar, where the tint would vanish.
The generated pixmap is cached per icon and cannot tell a menu from a
toolbar, so drawControl(CE_MenuItem) hands the base style an icon whose
Active pixmap is in the HighlightedText color instead.
QETApp::initStyle() installs the proxy on every platform, keeping the
base style's object name so the Fusion checks still match.
tests/qttest/tst_qeticons: hovering changes the icon ink to the tint and
stays at 3:1 on both palettes, on a raised button and on a checked one
drawn sunken, where the frame gives no hover cue at all; a colored icon is unchanged on hover; a
highlighted menu item's icon reads at 3:1 on the highlight bar.
inkcontrast.h gains background() and ink() helpers for those checks.
tst_menubarkeyboard's control case presses Alt+F and expects the File
menu to open. On macOS it never did, for two reasons unrelated to the
F10 shortcut it guards: a QMenuBar is native there, so its menus live
in the system menu bar where QTest key events do not reach them, and Qt
does not turn "&File" into an Alt+F mnemonic on macOS at all. The test
has failed on every Mac build since bd6bed8d6 added it.
On macOS the test now uses an in-window menu bar and switches auto
mnemonics on for its own process, which runs the same QMenuBar code the
other platforms exercise. What it still does not prove on macOS is the
native bar: QETMainWindow::activateMenuBar() calls setActiveAction() on
a bar the system draws, and only F10 in the running application can
say what that does there. Other platforms are unchanged.
Fixes#948.
Both panels forced a light palette on themselves (white rows, black
text, their own selection blue) so that element previews, which are
black line art drawn for the white sheet, would stay visible on a dark
desktop (bugtracker 335). On a dark palette the two docks were the
only white windows left.
The forced palettes are gone. Element previews are now kept as drawn,
on a transparent background, and adapted where they are shown:
ElementPreviewDelegate, installed on the collection tree, hands the
view a copy with its lightness inverted when the palette is dark
(QET::Palette::forPalette), so black ink becomes the palette's light
gray while colored icons such as folders stay as they are; the drag
pixmap is adapted the same way. A light palette shows the previews
untouched. This fixes bugtracker 335 on every dark desktop rather than
masking it with a white panel.
The preview cache stored the old white-sheet pictures; it records the
format now and drops a cache written before this change once. The
amber "show this directory" highlight sets black text so it reads on
both palettes. The Projects panel only shows icons from the icon
theme, which has a dark variant, so nothing else changes there.
tests/qttest/tst_qetpalette: the line-art rule tells ink from color;
inversion keeps hue and alpha; a preview reads at 3:1 on the Base
color of both palettes; in a tree on the dark palette the delegate
inverts a line-art icon and leaves a colored one alone.
Fixes#945.
The folio icons (Add, Remove, Properties, New folio, Title block
template) were anti-aliased gray page drawings, and the previous commit
left them untouched on the dark palette, where their soft gray fills
read blurry next to the line-art icons. They are now pixel-grid SVGs in
ico/scalable/ in the style of the Add PDF icon: a landscape sheet with
a title block line, a plus or minus badge in the corner, text lines for
properties, a filled title block for the template. Same 24 pixel canvas
as pdf-import.svg, same currentColor recoloring for the dark theme.
Only the 22 pixel PNGs go: five files leave ico/22x22 and both .qrc
files, and the alias list in misc/make_icon_themes.py that exposed
three of them under a second name is down to conductor2.png. The 16
pixel files stay, so menus and the projects panel keep their icons at
that size, and the 128 pixel diagram.png stays for the configuration
page list.
tests/qttest/tst_qeticons: every file in ico/scalable/ resolves in both
themes at 22, 24, 32 and 64 pixels, dark ink on light and light ink on
dark, with no 22 pixel PNG left beside it; the light-art check reads
the folio family at 16 pixels, where the page art remains.
The "Add PDF" action used ico/22x22/pdf-import.png, a white page with a
red PDF mark that sat apart from its neighbors "Add text" and "Add
image", both gray line art in a square frame with a plus. It is now
ico/scalable/pdf-import.svg, the same pixel design as insert-image.png:
a frame, the letters PDF, a plus in the corner. One file serves every
slot and stays sharp on high-DPI screens; the PNG is gone from both
.qrc files.
The canvas is 24 pixels with the art offset by one, like the Breeze
SVGs already in the theme. Fusion's toolbar slot is 24 pixels: a 22
pixel PNG is drawn unscaled inside it, but a scalable icon is rendered
at the slot size, and a 22 pixel grid stretched to 24 puts every one
pixel line between pixels and reads blurry.
The file uses currentColor like the Breeze SVGs already in the theme,
so misc/make_icon_themes.py produces the dark copy the same way. A new
ico/scalable/ folder holds QET's own vector icons; the direction
arummler asked for in #690.
tests/qttest/tst_qeticons: the icon resolves in both themes at 16, 22,
32 and 64 pixels, dark ink on the light theme and light ink on the dark
one, and no 22 pixel PNG remains.
misc/make_icon_themes.py sorted icons by saturation alone, so a white
page with a small red mark counted as line art and its dark copy turned
the page black: the PDF import icon read black on black (#919,
Kellermorph), and the folio, diagram and label icons came out as dark
pages with a light border.
An icon whose visible pixels are at least 30% near white is now "light
art" and inherits from the qet theme untouched; it already reads on a
dark toolbar. The generator also removes dark files it no longer
produces, so a reclassified icon falls back to the light theme instead
of keeping a stale copy. Thirteen files leave ico/themes/qet-dark.
tests/qttest/tst_qeticons: every dark theme file, taken as its mean
visible color, reaches 3:1 on the dark palette's window color; the
lightest-pixel check it replaces let a black page with a light border
through. Asking the dark theme for pdf-import, diagram, label, the
folio icons and diagram_bg returns the light art.
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
"Est il possible dans les raccourcis d'ajouter un pour création
automatique de conducteur ? Je n'utilise pas par défaut, mais
ponctuellement c'est très pratique." -- oc67, an electrician, on the
forum (viewtopic.php?pid=23296).
The action itself has existed for a long time: m_auto_conductor is a
checkable QAction in the Schéma toolbar and the Project menu. It was
simply never handed to ShortcutManager, so it did not appear in
Configuration > Raccourcis and there was no way to reach it from the
keyboard. This registers it.
No default sequence is set. That is the request read literally -- he
asked for it to be *in* the shortcuts list so he can bind it himself --
and it avoids spending one of the few free keys on a setting many people
never touch. The Shortcuts page already treats "no shortcut" as a normal
state: it renders an empty field and its quick filter can list actions
with and without a binding separately.
Verified on a virtual display. With shortcuts/diagrameditor.auto_conductor
set to Ctrl+Alt+A:
Configuration > Raccourcis, filtered on "conducteur", lists
"Création automatique de conducteur(s)" under "Éditeur de schémas"
showing that binding.
Mouse parked away from the toolbar, pressing it twice: the toolbar
button changes on each press and returns to its starting appearance
after the second, so the key toggles the setting exactly as clicking
the button does.
Two things that misled the first run, recorded so the next person does
not repeat them: F7 is already registered to panel.move_diagram_downx100
in the elements panel, and a screenshot taken with the pointer resting on
the button shows its hover state, not its checked state.
ctest 12/12, Qt 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CaKympWT3owLotCpEN2CFj
An electrician on the forum draws 400 V and 24 V circuits in the same
folio and wants the colour to be one click away
(qelectrotech.org/forum, viewtopic pid=23296). Today the quickest route
is F2, which opens a colour dialog and refuses to act unless exactly one
conductor is selected, so colouring a run means one conductor, one
dialog, at a time.
This adds a swatch button beside the auto-conductor actions. Picking a
colour does two things:
- recolours every conductor currently selected, as ONE undo step;
- becomes the colour of the next conductor drawn, through the
LastUsedStyle mechanism #888 already added and Conductor's
constructor already reads.
Either half is useful alone: with nothing selected it just sets the pen
for what comes next.
The menu lists the colours the trade names -- the three phases, neutral,
earth, and the ones used for control and extra-low-voltage circuits --
then any custom colours picked this session, then the full colour
dialog. A colour already in the standard list is not repeated under
"recently used".
Nothing is written to the project or to QSettings. That is deliberate:
it is the same session-scoped "what did I just use" idea as
LastUsedStyle, so it adds no persisted state and no file-format change.
Named presets stored per project -- what #461 actually asks for -- are a
larger feature that needs a maintainer decision first; the question is
still open on that issue since 21 June.
Verified on a virtual display against examples/Habitat-Schemas_developpes.qet,
reading colours back from the saved project rather than the screen:
select all on folio 1, pick Rouge
21 conductors {none:1, #ff5500:2, #ff0000:6, #00aa00:5, #0000ff:7}
-> all 21 #ff0000
one Ctrl+Z
-> back to the original five-colour mix, exactly
pick Marron with nothing selected, then draw a conductor
-> the new conductor is #7b3f00
ctest 12/12, Qt 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CaKympWT3owLotCpEN2CFj
Issue #903 reported that Escape stopped cancelling an in-progress
placement. PR #899 fixed that by letting Escape through to the active
tool whenever Diagram::eventInterfaceIsRunning(), and 863ac5f0a tightened
it to isRunning() so a second Escape cannot retrigger an abort already in
flight. That covers seven of the eight tools. It cannot cover the eighth.
eventInterfaceIsRunning() is m_event_interface && isRunning(), and
isRunning() returns m_running. DiagramEventAddText is the only class
under sources/diagramevent/ that never sets m_running, so the guard reads
false for the whole time the tool is armed and DiagramView::keyPressEvent
keeps swallowing Escape for its own selection/focus handling.
It is also the only one of the eight with no RightButton branch --
right-clicking the folio with the text tool armed opens the folio context
menu. So this tool currently has no way to cancel at all: the only way
out is to pick a different tool, and any stray click drops a text field
the user did not want.
The tool is armed from the moment it is attached, so m_running is set in
the constructor and cleared where the text is placed, before finish().
Measured on a virtual display against a fixture holding one free text
field, counting diagram-level text fields in the saved project:
arm the text tool, click 2 fields places, as it should
arm it, press Escape, then click 2 fields before -- not cancelled
1 field after -- cancelled
The same run on master's rectangle tool cancels correctly, which is what
made the text tool look fixed when it was not.
ctest 12/12, Qt 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Qt's QColorDialog loads custom colors from QSettings on startup but
never writes them back, so user-defined colors in the color picker
are lost when the application exits.
Save and load the 16 custom color slots explicitly via QSettings
in QETApp's constructor (after initStyle) and destructor (before
other settings are flushed). This covers every QColorDialog usage
in the application transparently.
Also fixes a QColorDialog memory leak in DiagramView.
189 of QET's 266 fixed-size icons are black line art with no dark
variant, so on a dark palette they were black on a dark toolbar, and
Fusion's disabled rendering lightened them into something more readable
than the enabled state (GitHub #466, #870; bugtracker 335 for the
element panels, which keep their own fix).
The theme "qet-dark" holds light-ink copies of the line-art icons in
ico/themes/qet-dark, generated by misc/make_icon_themes.py. Colored
icons are not copied; the theme inherits them from "qet". An icon counts
as line art when fewer than 20% of its visible pixels are saturated. The
copies keep hue and alpha and invert lightness, scaled so each icon's
darkest ink becomes (220,220,220), the dark palette's text color. The
eight SVG icons get their color replaced the same way.
QETApp::applyIconTheme() picks "qet-dark" for a dark palette and "qet"
otherwise. It runs from initIconTheme(), again from initStyle() once the
palette is final, and on the OS color scheme switch. Icons created with
QIcon::fromTheme() re-resolve on their next paint, so nothing else
changes.
With light-ink files, Fusion's own disabled rendering comes out dimmer
than enabled with no extra code.
tests/qttest/tst_qeticons: every name resolves in both themes, every
dark file has light ink, and a Fusion tool button shows its icon at 3:1
in both themes with disabled weaker than enabled.
Two fixes in the placement tool Ctrl+V starts.
Paste appeared on top of the original, not under the cursor. 55c2c0df9
added the placement tool precisely so the copy would not land invisibly
on top of what was copied, but 53a0f07ca then warped the pointer to the
group's grid-snapped origin -- which is the original's position -- so the
copy reappeared exactly over the original until the mouse was moved. The
start_pos the caller computes from the cursor was left unread: three
mentions in the file, all declaration or comment. Reported on #913,
where it reads as Ctrl+V pasting in place.
Move the group to the cursor instead of the cursor to the group. Both put
the copy under the pointer; only one of them takes the pointer away from
where the user put it. start_pos is honoured, the items are translated
once at construction, their conductors re-routed before anything is
drawn, and the movement baseline is set there too rather than waiting for
the first mouse move.
That made the baseline sentinel matter, so it is now the
m_baseline_captured flag the header already declared and nothing used,
rather than m_initial_cursor.isNull() -- which cannot tell "not set yet"
from a baseline that is legitimately scene (0,0).
Separately: one Ctrl+V killed the folio's right-click menu for the rest
of the session. init() sets Qt::NoContextMenu so a right click cancels
the placement instead of opening a menu over it, and nothing ever set it
back, taking "Coller ici", "Collage multiple", the folio properties and
add/remove column/row with it. Every other DiagramEvent* class restores
the policy in its destructor; this one did not. Confirmed against an
unmodified master build: the menu opens before a paste and not after one.
It matters here because "use the right-click menu instead" is the answer
people are given when Ctrl+V does not place where they wanted.
Verified on a built binary driven through a virtual X display, against
examples/741.qet and convertisseur.qet:
- the pointer does not move across Ctrl+V (1300,870 before and after;
on master it jumps to the original at 798,455), and the copy is under it
- a multi-element selection keeps its layout and its conductors re-route
- Escape and right-click both cancel, leaving nothing behind and nothing
on the undo stack; click and Return both commit; one undo removes the
whole paste and redo restores it
- with the pointer outside the viewport the copy lands at the viewport
centre, visible, and follows correctly once the mouse enters
- pasting into a different folio from the one copied from works
- the context menu opens before a paste, after a cancelled one, and after
a committed one, with "Coller ici" present
ctest 9/9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things raised in review, plus tests for the parts that were only
described in prose.
clearPendingCrashDump() did not do what its comment said. It called
pendingCrashDumpFiles() again at clear time, so it deleted whatever was
in the directory then, not what had been offered. The offer sits inside a
modal dialog that stays open as long as the user reads it, and
SingleApplication keys its socket on the binary path, so a second
QElectroTech build running alongside is a separate process that can crash
and write a dump in that window. Re-listing deleted that dump unseen --
the exact failure this change exists to fix. The list is now taken once
in QETApp::checkCrashDump() and passed to both
pendingCrashDumpContents() and clearPendingCrashDump().
The ring is now written before the backtrace. backtrace() unwinds through
libgcc, which calls dl_iterate_phdr and takes the loader lock; warming it
in install() removes the allocation but not the lock. Crashing inside
dlopen() (Qt plugin loading), or on a corrupted stack, could therefore
hang or re-fault the handler at the backtrace and lose the ring with it.
Order is now header, signal, ring, backtrace, so the cheapest and most
valuable part is already on disk before anything that can block. The
class comment claimed the handler takes no locks; that was not strictly
true and now says so.
QET_CRASH_BACKTRACE comes from find_package(Backtrace) rather than
__has_include(<execinfo.h>). The header exists on FreeBSD but backtrace()
lives in libexecinfo there, so the probe compiled and the link failed.
A crash_dump.log left by a pre-#905 version is migrated into crashes/ at
startup, named from its own mtime. Otherwise upgrading stranded it: the
new code never looks at that path, so the dump from the crash that
prompted the upgrade would sit there unoffered forever.
Also from the review: dumps are capped at the 10 newest, so a crash loop
cannot fill the log directory before any dialog is shown; crashDumpDir()
no longer creates the directory as a side effect of a const getter
(ensureCrashDumpDir() does that for the callers that write); and redact()
now masks an AppImage's per-run /tmp/.mount_XXXXXX prefix, which
backtrace_symbols_fd() writes into every frame.
Two test executables, both of which were checked to fail against the
behaviour they replace:
- tst_crashhandler covers CrashHandler::formatInt(), which had no
coverage at all despite running only inside a signal handler, where
nothing can assert: zero, negatives, INT_MIN (negated through unsigned,
since -INT_MIN is UB), INT_MAX, truncation and a zero-sized buffer,
each checked against a sentinel-filled buffer so a write past the
reported length fails.
- tst_crashdumps covers the bookkeeping: ordering, empty dumps, the
exclusion of this run's own path, the cap, concatenation of every
offered dump, that clearing deletes only what was offered, and what
redact() masks. qetlogger.cpp needs exactly one symbol from the
application, QETApp::dataDir(), which the test supplies itself.
Not addressed here: the timestamp in crash_<timestamp>_<pid> is the
launch time, not the crash time -- correct as observed, and the commit
message that implied otherwise was the thing that was wrong. Resolvable
QET frames for AppImage/Flatpak/Snap/Debian need -rdynamic and archived
debug symbols, which is a packaging discussion, not this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test bounded gdb with a backgrounded watchdog:
( sleep 120; kill -9 "$GDB_PID" 2>/dev/null ) &
WATCHDOG_PID=$!
wait "$GDB_PID"
kill "$WATCHDOG_PID" 2>/dev/null
sleep runs as a child of the subshell, so killing the subshell leaves the
sleep orphaned -- and the orphan still holds the write end of whatever this
script's stdout is. Run from a terminal that costs nothing. Run through a
pipe, which is how CTest invokes it, the reader sees no EOF until the sleep
expires, so every run lasted the full 120 seconds regardless of how fast
gdb finished. gdb itself takes ten.
That made modal_quit_regression 92% of the runtime of the entire ctest
suite (122 s of 133 s) and left it 60 s short of its own 180 s CTest
timeout -- near enough that a loaded CI machine could have turned it into
a flaky failure in somebody else's build.
Use timeout(1) instead, which leaves nothing behind, with a plain gdb call
as a fallback where it is unavailable. Suite time drops to 12 s.
Verified on the merged branch: fixed build passes both the .qet and the
read-only .elmt scenario, and with the fix commit reverted both still fail
with signal 6, under ~QETDiagramEditor() and ~QETElementEditor()
respectively.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test added with the #904 fix was run by hand. Register it so it runs
with the rest of the suite, and fix two defects found while extending it
to the element editor -- both of which made it report success it had not
earned.
nm -C "$BINARY" | grep -q <sym> under `set -o pipefail`: grep exits at the
first match, nm dies of SIGPIPE, and the pipeline reports failure. The
symbol check therefore skipped the test on every build that could actually
run it. Read nm's output into a variable once and match with `case`.
The input file was copied to a hardcoded "$SANDBOX/project.qet".
QElectroTech picks the editor from the extension, so passing a .elmt gave
a failed project load in the diagram editor rather than an element editor
-- the run still found a dialog, still called quitQET(), and still
reported a result, just for the wrong window. Preserve the basename.
With that fixed, the element-editor path is exercised on its own: a
read-only .elmt opens a message box, and on a build without the fix the
run aborts with "double free or corruption" under ~QETElementEditor(),
where before it named ~QETDiagramEditor(). The guard QETElementEditor
calls from its closeEvent() covers a real crash, not a theoretical one.
Registered on Linux only, and it reports 77 -- CTest's SKIP_RETURN_CODE --
when it cannot run at all: no gdb, a gdb built without Python, or a
stripped binary whose QETApp symbols it cannot call. A release build that
this test cannot drive is not a failing build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Save and restore the state of m_component_info_cb, m_fit_in_page_cb,
and m_use_full_page_cb via QSettings, in addition to the existing
ExportProperties-based checkboxes (border, titleblock, terminals, etc.)
- Add savePrintProperties() to persist all checkbox states after
print/export under the "print/default" settings prefix
- Remove unused QPrinter(HighResolution) local variable in launchDialog()
that caused an unnecessary ~2s CUPS round-trip on Linux
Fix multiple issues introduced by commit 55c2c0df9 (interactive paste):
Paste placement (diagrameventaddpaste):
- Items now load at their original XML coordinates instead of being
snapped to the cursor position, preventing jumps on initial placement
- Use delta-based movement: record the actual grid-snapped cursor
position on first mouseMoveEvent as baseline, then compute
grid-snapped deltas from there
- Bypass Diagram::snapToGrid() in moveTo() to avoid Ctrl modifier
causing pixel-snapping instead of grid-snapping
- Remove moveTo() from mouseReleaseEvent to prevent a final jump
on click
- Warp cursor to group bounding rect origin for visual feedback
Paste command (diagramcommands):
- Always clear PLC slave data (type, address, function, comment,
cross-ref, label, TC, T1-T4) on paste regardless of the
erase-label-on-copy user preference
- Block alignment (m_block_alignment / blockAlignmentUpdate) before
setElementInformations() for both slaves and non-slaves, preventing
finishAlignment() from shifting right/center-aligned text items
- Clear non-UserText items directly after setElementInformations()
for slaves as a safety net
draw-bezier-curves only exists as SVG in the qet icon theme; on
Debian/Ubuntu the SVG icon engine lives in qt6-svg-plugins, which
--no-install-recommends leaves out. breeze-icon-theme is not needed:
the test uses the qet theme compiled into the binary.
The ubuntu:26.04 container has no icon theme, so
QIcon::fromTheme() returns null icons and tst_qeticons fails
on first CI run. Install breeze-icon-theme so the test runs
against a real theme, as it does on developer machines.
openAndAddProject() shows BackupDialog as a stack object parented to the
editor and exec()s it; every QET::QetMessageBox does the same. exec() runs a
nested event loop, and closing the editor during it turns WA_DeleteOnClose
into a deleteLater() that the nested loop processes: ~QWidget() deletes the
editor's children, the stack-allocated dialog among them, and the process
aborts. Reported on macOS, where File > Quit lives in the application menu
and stays usable while the backup question is up.
The close is now refused while any modal widget is active, and the dialog is
raised so the refused quit is not silent. It is done in QETMainWindow::event()
rather than in closeEvent(), because QETDiagramEditor::closeEvent() starts
closing projects before it decides whether to accept. That covers the diagram
and title-block editors; QETElementEditor is a plain QMainWindow, so its
closeEvent() calls the same helper before canClose(), which itself opens a
modal. QETApp::quitQET() needs nothing: closeEveryEditor() goes through each
editor's close(), and quitQET() already only quits when every close succeeded.
Rejected alternatives, both suggested on the issue:
- Giving the dialog no parent stops the abort but not the deletion. One
caller of openAndAddProject() is the editor's own constructor, which goes
on to open the next file and call slot_updateActions() on this -- a loud
abort would become a silent use-after-free.
- Guarding only QETApp::closeEveryEditor(), which I first recommended on the
issue, misses the reported route entirely: File > Quit is connected to
QETDiagramEditor::close(), not to quitQET().
Verified on Linux, where there is nothing to click (the menu bar belongs to
the blocked window, and Qt ignores window-manager close requests for it), by
calling close() from gdb while the dialog's loop was running -- both
QETApp::quitQET() and QWidget::close() on the editor. Unfixed, both abort
with "free(): invalid size" in QObjectPrivate::deleteChildren() under
~QETDiagramEditor(), matching the report frame for frame; fixed, close()
returns false, the editor and the dialog stay up, and after answering the
dialog Ctrl+Q exits normally. The element-editor guard is the same helper
but was not exercised separately.
tests/modal-quit-regression/ turns that into a gate: it breaks on
QDialog::exec(), interrupts inside the nested loop, calls quitQET() and
checks the process survives. It matches no window titles (translated) and no
window ids, runs on the offscreen platform, and needs only gdb with Python.
Checked both ways: exit 1 with the backtrace above on a build without this
change, exit 0 with it.
ctest 8/8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When duplicating a diagram page, copied slave elements retain stale
data from the source: labels, descriptions, link references, and PLC
master information (type, address, function, comment, cross-ref, timer
values) remain in the copies.
Fix by adding clearPendingLinks() to Element to prevent copies from
linking back to source elements via stale UUIDs, and by cleaning up
copied element data after fromXml():
- Slaves always lose their label, formula, comment, location, and
PLC master data, since their text comes from a master element which
is not available on the copy. The displayed text on slave elements
is cleared directly via setPlainText(), but UserText items (free
text typed by the user) are preserved.
- For PLC slaves, setElementInformations() is called without
m_block_alignment so that elementInfoChanged() can run
finishAlignment() to correctly adjust text positions for the
cleared content.
- Non-slave elements respect the existing erase-label-on-copy
preference, same as PasteDiagramCommand::redo().
- Conductor labels are also reset when erase-label-on-copy is
active, matching PasteDiagramCommand::redo() (issue #413).
- Text alignment is preserved by wrapping setElementInformations()
with m_block_alignment for non-slave elements, same as
Element::fromXml().
Three weaknesses, all visible in ChuckNr11's report on #898 -- "the report
appeared only once despite there being 10 or more crashes".
One dump per run instead of one per install
-------------------------------------------
crashDumpPath() was a single fixed crash_dump.log, and the handler opens it
O_TRUNC, so each crash destroyed the evidence from the one before. Ten
crashes left one dump. Dumps now go to a crashes/ directory named
crash_<timestamp>_<pid>.log, and every pending one is offered together,
newest first, with a banner saying how many there are. A crash that repeats
is exactly the case where the earlier dumps matter, because the difference
between them is the evidence.
The name is built in normal context and handed to CrashHandler::install(),
which copies it into a preallocated buffer as before -- the handler still
writes to one fixed path, so its no-allocation invariant is untouched.
The dump now says which signal fired
------------------------------------
The header is built once at install(), so every dump looked identical no
matter what killed the process -- and SIGSEGV and SIGABRT point at very
different bugs. Written with an async-signal-safe integer formatter into a
stack buffer, since snprintf is not on the POSIX safe list.
...and where it was
-------------------
The ring said what the program was doing; nothing said where it died. The
dump now carries a backtrace. backtrace() is warmed once in install() so
its first-call lazy resolution cannot allocate inside the handler, and
backtrace_symbols_fd() writes straight to the fd -- unlike
backtrace_symbols(), which mallocs and must never be used here. Guarded on
__has_include(<execinfo.h>) so platforms without it are unaffected.
QET's own frames currently resolve as offsets rather than names, since the
binary does not export its dynamic symbols. They are still resolvable
offline: the header records the exact git SHA. Building with -rdynamic
would give names directly, but that is a build-flag decision for its own
change.
Deliberately unchanged: the four invariants in crashhandler.h. Nothing
added here allocates, blocks, takes a lock, or swallows the crash.
Verified: three consecutive SIGSEGVs now leave three separate dumps, each
carrying "Signal: 11" and a backtrace with resolved Qt frames; launching
afterwards offers all three in one dialog, newest first, and clears them
once shown. ctest 8/8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QET had no icon theme: the 446 entries of the icon table and the 116
iconsets in .ui files each named a resource path, so an icon could only
ever be one file, and a variant for another palette or a vector source
had nowhere to go (GitHub #466, #690, #870). This adds the theme layout
without changing a single pixel; a dark variant comes in a follow-up.
The theme "qet" follows the freedesktop layout Qt's icon loader
understands. misc/make_icon_themes.py generates ico/icon-themes.qrc,
which aliases the existing ico/<size>/<name>.png files into
themes/qet/<size>/<name>.png, and ico/themes/qet/index.theme. No file
moves. The four table entries that paired a 16 pixel file with a 22
pixel file of another name (ConductorSettings, DiagramAdd,
DiagramDelete, DialogInformation) get the 22 pixel file aliased under
the 16 pixel name.
QETApp::initIconTheme() registers the theme before initIcons() and makes
it current on every platform, so a desktop icon theme cannot replace
QET's icons. Icons are then looked up by name: QIcon::fromTheme() in
qeticons.cpp and in the few places that built a QIcon from a resource
path directly, and theme="..." on the iconsets in .ui files, with the
resource path kept as fallback. Flags, color swatches, application and
MIME icons stay on their paths.
One entry does not go through the theme. The elements panel draws the
project root with ProjectFileGP in the 50 pixel slot it reserves for
element previews, and the name "project" also carries the 128 pixel
file the configuration dialog uses. On a 2x display Qt's loader picks
that file for a 50 pixel request and fills the slot. ProjectFileGP
loads the 16 and 22 pixel files directly, as before.
tests/qttest/tst_qeticons: every name in the theme resolves, the four
aliases resolve at 22 pixels, a Fusion tool button shows its icon at
3:1 with disabled weaker than enabled, and the project root icon stays
at 22 pixels or less when asked for 50 at 2x while the configuration
dialog still gets its 128 pixel file. The rendering helpers shared
with tst_qetpalette moved to tests/qttest/inkcontrast.h.
QETApp::checkBackupFiles() only reached checkCrashDump() when there was
nothing to recover:
if (stale_files.isEmpty()) {
checkCrashDump();
return;
}
A crash with a project open always leaves a stale KAutoSaveFile, so on the
next launch the recovery prompt won every time and the dump sat on disk
unoffered -- the report was unreachable in exactly the case it is most
wanted. Reported by ChuckNr11 as a side note in #898: "the report appeared
only once despite there being 10 or more crashes". It appears on the runs
that happen to have nothing to recover.
Discussion #644 step 5 asks that the two prompts never show at the same
time, which this keeps: the recovery prompt is answered first, then the
report. The recovery half moves into offerBackupFiles() so both paths fall
through to the same place.
Verified under Xvfb, from a real crash state (SIGABRT with a project open,
leaving both a stale file and a 5.4 KB crash_dump.log): the recovery prompt
appears, and dismissing it now brings up "Rapport de plantage" carrying the
version, git SHA, OS, Qt version and the log ring. Before this change the
report never appeared -- that half rests on the four lines above rather
than on a captured before/after, since re-creating the crash state for a
clean baseline run kept consuming it.
Not addressed: the dump is a single fixed path opened O_TRUNC, so
consecutive crashes still overwrite one another.
ctest 8/8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a) paste
- conductor does not move
- pressing escape does not abort
- pressing anything during the process crashes the program instead of aborting
b) similar issue with the escape not working fixed for graphics items, texxt fields (and probably others)
Refs discussion #886: a saved-report manager built on custom SQL and
nomenclature.json. Most of what was asked for already existed --
Projet > Exporter au format CSV already builds/saves/reuses named
SELECT queries via ElementQueryWidget and nomenclature.json, and
Projet > Ajouter une nomenclature already inserts one into a folio.
This fills the three real gaps.
- projectDataBase::isReadOnlySelect() rejects anything that isn't a
single SELECT/WITH statement. Checked in newQuery() itself, the one
choke point every query path already goes through -- including a
query loaded from a saved nomenclature/summary table's <query>
element on project open, not just the dialog's own custom-SQL box.
ElementQueryWidget shows the same check live as you type, and
BOMExportDialog surfaces it before running or exporting anything.
- BOMExportDialog gains a Preview button + table (QSqlQueryModel),
so a report can be checked on screen before committing to a CSV file.
- ElementQueryWidget gains Importer.../Exporter... buttons that
read/write nomenclature.json's saved reports as a JSON file, so a
report can be handed to a colleague or another install. Import asks
before overwriting a locally-saved report of the same name.
Verified: Qt 6.10.2, builds clean, ctest 8/8. Drove the real dialog
through Xvfb: typed "DROP TABLE element" into the custom-SQL box and
got the inline warning immediately, then confirmed Preview also
refuses it with a "Requête refusée" dialog rather than running it.
Preview against the real default query returned live column headers
and a row. Export opens a save dialog without crashing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Another exception: one non-converted code path in projectprintwindow.cpp to be followed-up.
One issue found in the Qt6 code path of diagramview.cpp which has been fixed.
Addresses scorpio810's review of PR #890 (bugtracker #734):
- The four cas "3"/"4" bridge-skip guards now compare qRound()ed
coordinates, matching how the bridge coordinate itself is computed --
an exact != would miss a pair already grid-equal after rounding but
off by a sub-pixel remainder, and still route a degenerate bridge for
it. Verified no behavior change on the shipped corpus: per-file
self-retrace counts are identical before/after (every coordinate
there already lands exactly on-grid).
- Added tests/qttest/tst_conductorselfretrace.cpp, fixture
qet_bug_repro_resaved.qet (the report's own canonical reproduction):
exports it via the built binary's --export-svg and asserts no
conductor path is self-retracing. Confirmed it actually catches the
regression, not just passes vacuously -- reverted conductor.cpp to
master and reran: fails, 1 self-retracing path found.
Not changed in code: the cas "4" descending-branch dead-code note (kept
for symmetry, as already agreed) and the schema_unifilaire_voltaique2.qet
trade-off (flagged for the reviewer's own visual check).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
update_translations dropped 3 .ui files' <location> entries this run
(graphicstablepropertieseditor.ui, inditextpropertieswidget.ui,
projectdbmodelpropertieswidget.ui), marking ~38-44 existing translations
per language as vanished -- lrelease drops those, so merging would have
lost real, in-use translations in about twenty languages. Cause not
yet identified.
scorpio810 will run lupdate separately after merge, so the 37 new
scripting strings get picked up without this loss and without
conflicting with translation work in progress.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses scorpio810's review of PR #891:
- addElement() now imports the element into the project's own embedded
collection first (QETProject::importElement()), same as the
drag-from-collection-panel path -- otherwise the saved .qet referenced
a definition outside the project, missing on another machine. Also
found and fixed while wiring this up: ElementsLocation::setPath()
forces any path to embed:// once a non-null project is passed, so the
unconditional ElementsLocation(locationPath, m_project) this method
used before silently broke every common://custom:// call. Refuses
on an import-collision case that would otherwise reach
QETProject::importElement()'s own modal ImportElementDialog, with
nobody there to answer it in a script.
- addElement()/setElementPosition()/moveElement()/deleteElement() refuse
on a read-only project, matching their GUI equivalents.
- save() goes through QETProject::write() when no path is given (read
-only handling, saveddate/savedtime, QSaveFile via writeXmlFile()) and
QET::writeXmlFile() directly for an explicit output path, instead of a
plain QFile that skipped all of that.
- Interactive "Run Script...": exports briefly disable project backups
around the temporary QETProject CLIExport::run() opens on the same
file, so it doesn't race the real open project's own KAutoSaveFile.
Restored right after -- the headless --run path is untouched, since it
depends on backups staying off for the whole run.
- A watchdog thread now calls QJSEngine::setInterrupted() after 30s,
so a runaway script (`while(true){}`) can't freeze the GUI or hang a
CI job forever. First version used sleep_for() and blocked every run,
fast ones included, for the full 30s on join() -- caught by testing a
one-line script, fixed with wait_for() on a condition variable so a
script that finishes early wakes the watchdog immediately.
- Interactive script errors now also show a QetMessageBox, not just
stderr (invisible on Windows).
- Ran update_translations (lupdate) to pick up the 37 strings this
feature had not yet added to the .ts files.
Not applied: wrapping the whole script run in one undo macro. It would
make the script's own qet.undo()/qet.redo() calls silent no-ops for the
run's duration -- QUndoStack ignores undo()/redo() while a macro is
open -- which would break that already-shipped, explicitly requested
capability to get one convenience Ctrl+Z instead.
Verified: Qt 6.10.2, builds clean, ctest 6/6. Ran each fix against a
real project: addElement() on a common:// path now succeeds and the
saved file embeds the definition (embed://import/...); read-only
project refuses addElement(); save("") and save(otherpath) both
produce a correctly embedded file; `while(true){}` under --run is
interrupted at 30s where it previously hung forever, and a normal
script now exits in ~0.3s instead of blocking for the full timeout
budget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add !parentGroup() guard to stale xref removal in updateXref()
to prevent deleting xref data that ElementTextItemGroup::updateXref()
just stored for a grouped text item.
- Replace QStringLiteral("xref") with QETInformation::ELMT_XREF
in linkelementcommand.cpp and masterelement.cpp.
- Fix indentation in plclinkwidget.cpp.
The Selection properties "texts" tab painted the color value's own text in
that color, so the default black was unreadable on a dark palette. Show a
swatch in the cell instead and leave the text in the palette's color.
The terminal plan preview draws black ink with a white brush, like the
printed page it previews, on whatever background the view inherits from
the palette. Give the view a white background.
main.cpp has forced the Fusion style on macOS since 2019, but the palette
still came from Qt's macOS platform theme, which is built for the native
style. It hands Fusion a Window, Button and Base that are the same color,
a Dark lighter than Light, and in dark mode an Inactive ButtonText of
black. Fusion derives its frames, gradients and indicators from those
roles, so fields had no edges, the radio buttons in the text alignment
dialog vanished, and the "Handles" combo box in the diagram editor
toolbar drew its text black on a dark combo the moment the window lost
focus. Light mode had the same flatness, with Window, Button and Base
all white.
Add QET::Palette (sources/qetpalette.{h,cpp}) with a light and a dark
palette laid out the way Fusion expects, and install one from
QETApp::initStyle() on macOS when the running style is Fusion, choosing
by the platform palette's lightness and keeping the platform's accent
color when it reads at 4.5:1. On macOS the base palette is now applied
whether or not "use system colors" is checked, since the system palette
cannot be drawn by Fusion; that setting only decides whether style.css
is layered on top (#467). On Qt 6.5+ the app follows the OS light/dark
switch through QStyleHints::colorSchemeChanged.
Other platforms are untouched: Fusion is Qt's default style on Linux
desktops without a platform theme, and the palette there carries the
user's desktop colors. Making Fusion and this palette the default
everywhere is discussed in #870.
tests/qttest/tst_qetpalette checks every text role pair at WCAG 4.5:1
(3:1 disabled), that Inactive equals Active, and paints a Fusion combo
box, radio buttons, buttons and a line edit on the offscreen platform to
measure the ink against its background. Set QET_TEST_DUMP_DIR to keep
the rendered images.
- Element::reloadPicture() now returns a ReloadPictureResult and never
clears the current drawing before a successful rebuild: a missing or
unreadable definition leaves the element as it was instead of blank.
- Elements whose size, hotspot or terminals (added, removed or moved)
differ from the new definition are not redrawn: the new drawing would
no longer match their bounding rect and live terminals.
- The action lists those elements and warns that they must be removed
and re-inserted, which deletes the conductors already connected to
them.
- Status tip states the action is not undoable.
Terminal::~Terminal() called qDeleteAll(m_conductors_list) on the live
member. Each Conductor destructor calls removeConductor() on both of its
terminals, and that removes the conductor from the same list qDeleteAll
is iterating. Mutating a QList while iterating it is undefined
behaviour; with two or more conductors on one terminal (terminal strips,
bridged terminals) it can skip a delete or delete one conductor twice,
which leaves another conductor's terminal1/terminal2 pointing at freed
memory.
The pattern dates from a00404bc9 (2021), which replaced a foreach loop
(iterating an implicit copy) with a direct qDeleteAll. It went unnoticed
until the deterministic sort keys added to Diagram::toXml() in #844
started reading pos() on both terminals of every conductor on every
save, including the periodic backup, which turned the stale pointer into
an EXC_BAD_ACCESS in QGraphicsItem::pos() while deleting an element.
Copy the list first and delete from the copy, restoring the pre-2021
behaviour. An isolated regression test (a hub terminal with 2 to 8
conductors, under AddressSanitizer) did not trigger the failure with the
old code, so none is included; the crash analysis and that attempt are
recorded in jp2images/qelectrotech-source-mirror#1.
LineEditor::setPart() no-ops (skipping updateForm()) when the part
passed in is already m_part. That is harmless when the editor widget
is torn down between selections, but this branch keeps the same
editor instance installed across selection changes instead of
recreating it, so a line already shown alone can also be
parts.first() of a later multi-selection -- and the x1/y1/x2/y2
spinboxes then keep showing whatever was in them before, not this
selection's actual first line.
setParts() now always calls updateForm() after setPart() succeeds,
closing the gap regardless of the identity check inside setPart().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends the scripting surface from the previous commit with exactly
the three things explicitly scoped out there, per follow-up direction:
editing geometry, undo integration, and driving the GUI -- the last
one narrowed to select/zoom/message after discussion, since "invoke
any menu action by name" would let a script trigger a modal
QDialog::exec() with nobody there to dismiss it, the same hang class
investigated for bugtracker #882.
## New capabilities
- addElement/setElementPosition/moveElement/deleteElement, through the
same undo commands the GUI itself uses: AddGraphicsObjectCommand
(the same one drag-from-collection-panel placement uses),
QPropertyUndoCommand on the standard `pos` property, and
DeleteQGraphicsItemCommand (refuses a non-deletable terminal, same
as the Delete key).
- undo/redo/canUndo/canRedo against the project's real QUndoStack --
the same one QETDiagramEditor's Ctrl+Z is wired to via
undo_group.activeStack(), not a parallel mechanism.
- selectElement/deselectAll (scene state, no view required -- works
headless), zoomFit/zoomToContent/zoomReset (need the active
DiagramView, so false headless where there is nothing to zoom), and
showMessage (a modal QET::QetMessageBox::information -- safe headless
because non-interactive mode is already on for the whole process
before any script runs).
## Two real bugs caught by testing this, not assumed away
1. save() was still going through the same reopen-from-disk path as
every export method: it opened a *second*, unmodified copy of the
project from its file on disk and rewrote that. addElement() and
friends operate on the live in-memory project, so nothing they did
ever reached the saved file -- an element counted correctly in
memory and then silently vanished from the output. Fixed by having
save() write m_project->toXml() directly, the only method that
touches the live instance rather than a fresh copy of the file.
2. A script calling setElementPosition() then moveElement() on the
same element produced a saved position that didn't match either
call, and undo/redo didn't step through them independently. Traced
to QPropertyUndoCommand::mergeWith() (pre-existing, not new):
consecutive commands on the same object+property merge when their
text() also matches, and both calls build the identical "Déplacer
%1" text for a given element -- exactly the same collapsing
dragging an item repeatedly gets. Not a bug in the new code; a
wrong assumption in the first test. Re-verified against the
correct, merge-aware expectation: add -> merged move -> undo (back
to first position) -> undo (element removed) -> redo (element back)
-> redo (merged move reapplied) landed at the exact predicted final
position, read back from the saved XML.
## Verified
Qt6, build clean from a fresh reconfigure, ctest 6/6.
- Headless: addElement returns a real uuid and the count updates;
select/set-position/move all report correctly; the merge-aware
undo/redo/save round trip above, confirmed against the saved file's
actual XML, not just in-memory counters.
- Corpus: the existing read-model smoke script re-run against all 24
shipped example projects on the fixed binary, 0 failures.
- zoomFit correctly returns false headless (no view to act on),
confirming the "narrowed GUI-driving" scope holds in code, not just
in the doc comment.
- GUI: running the add-only script via "Exécuter un script..." marked
the project [modifié] in the title bar, the same change-tracking
path a manual edit goes through -- consistent with the undo command
actually being pushed onto the project's real stack rather than some
side channel invisible to the rest of the application.
Refs #162.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Following up on my own comments there: a deliberately small, mostly
read-only scripting surface, exposed to scripts as a single global
`qet` object (QetScriptApi) built on QJSEngine rather than an embedded
Python interpreter -- no new toolchain to package (QJSEngine ships in
every Qt SDK QET already targets, via the Qml module), no GIL, no
version pinning, automatic reflection of the QObject-derived core
classes' own methods with no hand-written binding layer.
## What a script can do
- Read the model: project title, file path, folio count/titles,
element/conductor counts per folio.
- Trigger the same operations the --export-* CLI flags already do
(pdf/png/svg/cables/wires/bom/wiring/nets/links/info), plus
set-titleblock and save -- thin wrappers around CLIExport::run(),
reusing its already-tested logic rather than duplicating it.
Deliberately NOT in this version: creating or editing diagram
geometry, undo integration, driving the GUI. All explicitly out of
scope per the discussion on #162.
## Two entry points, both built and tested
- `qelectrotech --run script.js project.qet` -- headless/CI.
- Projet > "Exécuter un script..." -- an interactive macro against the
currently open project. Export/save calls act on the project's file
on disk (see QetScriptApi's class comment for why), so unsaved GUI
edits aren't visible to the script; save first if that matters.
## Optional dependency, not a hard requirement
Qt::Qml is probed the same way QtPdf already is in this codebase:
QUIET, non-fatal, behind a QET_HAS_SCRIPTING compile definition. A
build without it compiles and links identically; the CLI flag and
menu action are simply absent (main.cpp) or compile to a clear
"not available" stderr message rather than silently disappearing
(qetscripting.cpp), matching the existing QtPdf pattern rather than
introducing a new one.
One real bug caught building this, not assumed away: my first pass
conditionally excluded the new source files from QET_SRC_FILES behind
`if(QET_HAS_SCRIPTING)` inside qet_compilation_vars.cmake -- but that
file is included before QET_HAS_SCRIPTING is set in the top-level
CMakeLists.txt, so the variable didn't exist yet at that point and the
files were silently never compiled, only caught by an undefined-symbol
link error. Fixed by following the QtPdf file's own precedent:
compile the files unconditionally, guard their Qt::Qml-dependent
content internally instead.
## Verified
Qt6, build clean, ctest 6/6.
- Headless: a script reading project/folio/element/conductor counts,
calling exportInfo() and exportPdf() against a real project --
correct JSON, a real single-page PDF confirmed with `file`.
Error paths: a thrown script exception reports file:line:message and
exit 1; missing script/project arguments exit 2 (matching
CLIExport's own usage-error convention); a missing project file is
reported and does not hang.
- Corpus: the same read-model script run against all 24 shipped
example projects, 0 failures.
- GUI: "Exécuter un script..." opens a real file dialog filtered to
*.js, running the picked script against the live open project
produced the exact expected JSON export file, and the application
was still fully responsive afterward.
Refs #162.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
generateConductorPath()'s cas "3"/"4" branches each insert a two-point
bridge along one axis, at a coordinate on the other axis computed as
the midpoint of depart/arrivee and then snapped to the routing grid by
a loop that walks it strictly downward until it divides evenly.
When depart and arrivee already agree on the axis the bridge would run
along, no bridge is needed at all -- the midpoint starts on the
correct, already-shared value. But the snap can still walk it off that
value, or, when it happens to already sit on the grid, the two bridge
points just duplicate depart and arrivee outright. Either way the
conductor renders with an unnecessary out-and-back excursion or a
small looping detour at a join that needed neither.
Fix: skip the bridge in exactly that degenerate case, per branch, on
the axis that branch actually bridges on. descendant and montant are
not mirror images of each other in which axis each cas guards on --
each site says so.
Verified against the report's own canonical reproduction
(qet_bug_repro_resaved.qet from the linked gist): 1 self-retracing
path -> 0. Full corpus of 24 shipped example projects (stored
segments, as shipped, not regenerated): 74 -> 65 self-retracing
conductor paths, zero regressions -- only two files changed, both
improved.
One disclosed trade-off, found while checking for regressions: in
schema_unifilaire_voltaique2.qet, 8 terminal pairs closer together
than twice the extension length (docked stubs already cross before
any bridge is considered) go from an existing small rectangular-loop
artifact to a straight out-and-back retrace covering the full gap --
same count of defective paths (8 -> 8), a different shape, still no
connectivity change either way. Not chased further; a real fix for
that narrower "crossed stubs" case is a separate piece of work.
The shipped affuteuse_250h.qet's own 12 self-retracing paths (verified
by stripping all 185 stored <segment> elements and forcing full
regeneration) are unchanged by this fix -- they are a structurally
different point-count signature (a 4-point back-and-forth reachable
from cas "1"/"2", not the cas "3"/"4" grid-snap bridge this fixes),
consistent with what the issue thread already flagged as a separate,
undiagnosed mechanism.
Refs #734 (own root-cause comment, 2026-08-13).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A placed element is drawn once from its definition at construction --
buildFromXml() only turns terminal/input/dynamic_text tags into live
child objects, every other primitive (line, rect, ellipse, polygon,
arc, text) is pre-rendered into a QPicture by ElementPictureFactory,
cached forever under the element's uuid with no invalidation path
anywhere in the codebase. Edit and save a symbol's drawing and every
already-placed instance keeps showing the old one until the project
is closed and reopened.
Fix, scoped to what is safe to do without ever risking a conductor or
a dynamic text's per-instance state:
- ElementPictureFactory::dropCache(location) forgets the cached
drawing for one location, so the next fetch rebuilds it from the
definition's current content.
- Element::reloadPicture() re-fetches and repaints one instance.
- Projet > "Recharger les dessins des éléments": walks every diagram,
drops each distinct location's cache once, then reloads every placed
instance.
Deliberately does not touch terminals or dynamic texts -- a definition
whose terminal positions moved still needs the existing remove-and-
reinsert workflow, since terminals are what conductors are attached to
and a wrong guess there would silently misconnect wires.
Verified: build clean, ctest 6/6. Triggered the new action on a real,
densely-wired project (76 elements) via exact keyboard-menu navigation
cross-checked against the menu's own addAction order -- ran to
completion, correct confirmation dialog, no crash, diagram unchanged
and uncorrupted afterward. Could not complete a live edit-and-watch-
it-update trace: opening the element editor on a selected item via
GUI automation was unreliable in this environment (same class of
friction as PR #888), and this sandbox has no file-based (common://)
element to mutate on disk as a shortcut -- every example project
embeds its elements. The mechanism itself is traced correct:
ElementsLocation::xml() for an embed:// location reads the project's
live in-memory collection DOM on every call, so a dropped cache
rebuilds from whatever was most recently saved.
Refs #802 (own analysis comment, 2026-08-31).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The F2 color editor recolors one conductor, but the next one drawn
falls straight back to defaultConductorProperties -- the choice made
via F2 is lost the moment you place another wire, and lost again on
restart. LastUsedStyle already solves the same problem for shapes
(pen/brush) and free text (font), session-scoped and deliberately not
QSettings-backed; this extends it with a conductor color, following
the identical has/get/set shape.
F2's handler records the color after pushing its undo command.
Conductor's constructor -- the one place a new conductor's properties
are set from defaultConductorProperties -- overrides just the color
field when a session color has been recorded, leaving every other
default (style, thickness, text) alone.
Verified: build clean, ctest 6/6. Could not get a reliable headless
GUI trace of "F2 one wire, draw a new one, see it inherit the color"
-- drag-and-drop element placement under Xvfb was unreliable in this
environment (one attempt did nothing, another drew an unintended long
conductor undo didn't fully clear). The code path is otherwise
identical to the already-shipped shape/text mechanism this mirrors.
Refs #461.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
setQuery() restored the loaded SQL text into the line edit but never
restored the "Edit SQL query" checkbox, so a custom query (a join, a
subquery, a view other than project_summary_view) displayed correctly
while the widget stayed in built-in mode. queryStr() only consults that
checkbox, so accepting the dialog without touching anything silently
replaced the custom query with a freshly generated one.
Detect it instead of trusting a flag that was never set: after parsing
columns from the loaded query, rebuild the query those columns would
produce and compare it against what was loaded. A mismatch means the
widget cannot reconstruct it, so it must be user-written -- check the
box and keep the literal text.
Verified against both cases this has to get right, not just the one
in the report: a genuinely custom query (join) now round-trips through
an untouched accept+save byte-for-byte, and a plain built-in query
written before the #238 pos-ordering fix (examples/industrial.qet, no
ORDER BY clause) is classified as custom rather than silently gaining
an ORDER BY it didn't have -- it round-trips unchanged rather than
being corrupted, though its column picker is now disabled until a user
rebuilds it by hand.
Based on the patch attached to #885.
QETProject::m_uuid was created in the constructor and never written, so
a project got a new uuid every time it was opened. Inside a running
instance it is only used to name the SQLite connection, but nothing
outside the instance could tell which project a file belongs to.
Motivation
A .qet file is increasingly handled by tools outside QElectroTech: Git
repositories on GitHub or GitLab, cloud storage, key-value stores,
per-project locks. All of them need a stable key for "this project":
- The file name and path are not stable: files get renamed, moved,
checked out in different places.
- The project title is user-editable and not unique.
- Folio uuids (persisted separately) are only unique within their
project; keying folios globally needs a project identifier as well,
e.g. projects/{projectUuid}/folios/{folioUuid}.
Change
Write the uuid as an attribute of <project> and restore it in
QETProject::openFile(), right after parsing and before the project is
built from the XML. Older versions ignore the attribute, so files stay
readable in both directions.
The project database is not affected: it takes its connection name
from the uuid created at construction (m_uuid is declared before
m_data_base), before the file is read. Two open projects carrying the
same persisted uuid therefore still get distinct connections.
Files without a uuid: why not a random one
Keeping the random uuid created by the constructor and saving it
conflicts with #754 / #779: saving an unmodified project must give the
same bytes every time. Every example project predates the attribute.
Measured on the 24 example projects (resaved 3x each from the same
original, QT_HASH_SEED=0 so that QDom's attribute order is stable,
isolated HOME per run):
upstream master 23/24 byte-identical
persist, random uuid 0/24
persist, derived uuid (this) 23/24
The remaining project, schema_indus.qet, differs only in element uuids,
the known residual #779 leaves for elements; its project uuid is
stable.
Instead, a project file without a uuid gets a name-based (version 5)
uuid derived from the raw content of the file:
QUuid::createUuidV5(<fixed QET project namespace>,
"qet-project-legacy\n" + file content without CR)
- The same file always yields the same uuid, so resaving an unmodified
legacy project stays reproducible.
- Different projects practically never share a uuid, because any
difference in content gives a different one. This is unlike folios,
where only data such as title and position could be used; the raw
file bytes are stable input for the whole project.
- Carriage returns are dropped before hashing. QFile's Text mode already
strips them on Windows but not elsewhere, and git's autocrlf can
change them on checkout; either way the uuid is the same on every
platform.
- The uuid is derived once, at load time, and saved from then on. After
that it is read, never recomputed: renaming the project, editing it
or changing it in the same session as the migration does not change
it.
- Two people opening the same legacy file on different branches get the
same project uuid.
The namespace uuid is fixed in the code and must never change, or every
legacy project would get a different uuid.
Known limitations, open for discussion
- Copies share the uuid. Two byte-identical legacy files get the same
uuid (examples/cablage-eclairages_sikli-v5.qet and
câblage-éclairages-sikli-v5.qet are such a pair), and so does a
migrated file copied in the file manager or saved with "Save as".
That is what identity means for a copy, and the same happens with Git,
but a tool that treats the uuid as globally unique has to cope with
it. Regenerating the uuid on "Save as" could be a follow-up, if that
is the preferred behaviour.
- A legacy file that differs from another only in formatting (e.g.
re-indented) gets a different uuid. The two sides of a merge only
agree if they started from the same bytes, which is the normal case.
Tests (Qt 6.4, offscreen, qelectrotech --resave / --set-titleblock /
--info)
- 24 example projects, 3 resaves each from the same original: results
above; the project uuid is identical across runs. All 24 uuids are
distinct, except the byte-identical pair mentioned above.
- Resaving an already migrated file is byte-identical to the first
output.
- The same legacy file with CRLF line endings gets the same uuid as
with LF.
- Changing the project title in a migrated file keeps its uuid.
- Migrating and modifying in the same run (--set-titleblock on a legacy
file) gives the same uuid as a plain resave.
- Re-indenting a legacy file gives a different uuid (expected).
- A migrated file opened with upstream master loads normally; the
attribute is ignored and dropped on save.
- --info on a migrated file still works.
Refs #754, #779
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDyt4txaott5JyPNGQaeVp
Diagram::m_uuid was created in the constructor and never written, so a
folio got a new uuid on every load. Inside a running instance that is
enough (the project database keys on it), but nothing outside it could
tell which folio is which: in the file, folios were only identified by
their position.
Motivation
More and more .qet projects live in version control -- a Git repository
on GitHub or GitLab, reviewed through pull requests, sometimes edited
by several people -- or are synchronised through a cloud or key-value
store. A .qet file is plain XML, so in principle it can be diffed,
merged and split up, but only if the same folio can be recognised in
two versions of the file. Today it cannot:
- Inserting, deleting or reordering a folio shifts every following
<diagram> element. A line-based diff, and GitHub's review view, then
pair up unrelated folios and show far more change than was made.
- A three-way merge of two branches that both touched the project has
no way to match "folio 3" on one side with "folio 3" on the other if
either side reordered folios.
- Any tool that wants to say "folio X changed in this commit", keep
per-folio history, lock a single folio, or store folios as separate
objects has nothing stable to key on. The title and the folio number
are user-editable and not unique.
Element uuids are already persisted and used for cross-folio links, so
the file format already relies on uuids for identity; the folio itself
was the missing piece. A stable folio uuid is the prerequisite for
later work towards better version control support: per-folio diffs and
locks (check-out / check-in), and possibly storing a project as a
directory with one file per folio.
Change
Write the uuid as an attribute of <diagram> when the whole content is
saved, and restore it first thing when the project is loaded, before any
item is created. Older versions ignore the attribute, so files stay
readable in both directions.
Folios without a uuid: why not a random one
The obvious migration -- keep the random uuid created by the
constructor and save it -- conflicts with #754 / #779: saving an
unmodified project must give the same bytes every time. Every example
project predates the attribute, so each load would invent different
uuids and write them out. Measured on the 24 example projects (resaved
3-4x each from the same original, QT_HASH_SEED=0 so that QDom's
attribute order is stable, isolated HOME per run):
upstream master 23/24 byte-identical
persist, random uuid 0/24
persist, derived uuid (this) 23/24
The remaining project, schema_indus.qet, differs only in element uuids,
the known residual #779 leaves for elements; its folio uuid is stable.
This is the same problem #779 solved for conductors by not writing an
invented uuid back at all. That is not an option here: legacy folios
would never get a persistent uuid, which is the whole point of the
change. Instead, a folio without a uuid gets a name-based (version 5)
uuid, derived only from data read from the file:
QUuid::createUuidV5(<fixed QET folio namespace>,
"legacy" + project title
+ position of the folio in the file
+ folio title)
- The same input file always yields the same uuids, so resaving an
unmodified legacy project stays reproducible.
- The uuid is derived once, at load time, and saved from then on. After
that it is read, never recomputed: renaming, reordering or editing
the folio later does not change it. Renaming in the same session as
the migration does not change it either, since it was derived from
the title as loaded.
- Two people opening the same legacy file on different branches get
the same uuid for each folio, even if one of them reorders or renames
folios before saving. With random uuids the two branches would
disagree about every folio and a later merge could not match them.
- The folio content is deliberately not part of the name: QDom keeps
attributes in a hash whose iteration order changes between runs, so
hashing the content would need a canonical form for no real gain.
Folios are only guaranteed unique within their project. Two unrelated
legacy projects with the same title and the same first folio title get
the same uuid for that folio; anything keying folios globally has to
combine the folio uuid with a project identifier. (The project uuid is
not persisted yet; that is a separate change.)
Duplicated uuids
A hand-edited or merged file can contain the same uuid twice, e.g. a
folio copied by duplicating its XML block. Since the uuid is used as a
key, the second folio gets a derived uuid as well ("duplicate" + the
clashing uuid + the same inputs as above), so this case is
reproducible too. Should a derived uuid ever be taken already, which
takes a hand-crafted file, the name is salted with a counter until it
is free.
The namespace uuid is fixed in the code and must never change, or every
legacy folio would get a different uuid.
Tests (Qt 6.4, offscreen, qelectrotech --resave / --set-titleblock)
- 24 example projects, 3-4 resaves each from the same original: results
above; all folio uuids identical across runs, no duplicates within
any project.
- Resaving an already migrated file is byte-identical to the first
output.
- Renaming a folio in a migrated file keeps its uuid.
- Swapping two <diagram> blocks in a migrated file: each uuid moves
with its folio.
- Migrating and renaming in the same run (--set-titleblock title=...
on a legacy file) gives the same uuids as a plain resave.
- A file with a duplicated uuid: the second folio gets a new uuid, the
same one on every run.
- A migrated file opened with upstream master loads normally; the
attribute is ignored and dropped on save.
Refs #754, #779
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDyt4txaott5JyPNGQaeVp
Ctrl+V pasted in place, which put the copy exactly on top of the original.
Nothing appeared to happen: the only clue was a doubled outline, and the
copy had to be dragged off the original to be seen at all. The cursor was
ignored entirely.
Ctrl+V now starts a placement. The items appear under the cursor and follow
it until a left click or Return drops them; Escape or a right click takes
them away again. That is the same interaction as placing a new element, so
paste behaves like every other way of putting something on a folio, and the
copy lands where the user is looking.
Implemented as a DiagramEventInterface beside the existing add-element and
add-macro tools. The pasted items are the real ones from the start rather
than a preview: Diagram::fromXml creates them exactly as before, this class
moves them, and PasteDiagramCommand is pushed only once they are dropped.
PasteDiagramCommand's first redo() deliberately does not add items to the
scene -- it assumes fromXml already did -- so pushing it on commit adopts
them rather than duplicating them. One copy of the paste logic, and a
cancelled paste leaves nothing on the undo stack.
Conductors are not moved directly; they are drawn from their terminals and
follow the elements they attach to. On cancel they are removed before the
elements, so none is left in the scene holding a pointer to a freed
terminal.
Verified by counting elements in the saved file rather than by eye:
56 to start, 56 after paste-then-Escape, 57 after paste-then-drop, and 56
again after undo. Save determinism run against this build: pass, no
regressions against baseline. Tests 5/5 on Qt 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two claims in the previous comment were wrong.
"Qt implements F10 on Windows but not on X11" was inference I cannot test
here. What is measured is narrower and enough: QMenuBar given Key_F10
directly leaves it unaccepted, and sent to the window the key never reaches
the menu bar at all, because a key press goes to the focused child widget.
"&Édition takes É, which is not on a UK or US keyboard" was wrong outright.
It came from running an uninstalled binary, which cannot find its .qm files
and falls back to the French source strings. With translations loaded the
menus read File, Edit, Project, Display, Settings, Windows, Help, and Alt+E
opens Edit.
The comment now also says plainly that this is convenience rather than
access: Alt tap focuses the bar and Alt with a letter opens a menu, both
verified working, so the menus were already reachable without a mouse. F10
is the key people reach for out of habit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GtMZqGEiUMvDBqcFvVG2vb
F10 opens the menu bar in most applications and is the usual way to reach
the menus without a mouse. Qt provides this on Windows but not on X11, so on
Linux the key did nothing and the press fell through to whichever widget had
focus. It matters more here than it might elsewhere: "&Édition" takes É for
its own letter, which is not on a UK or US keyboard, so that menu has no
direct Alt route at all.
A window-context QShortcut rather than a key handler -- key presses go to
the focused child widget, so a keyPressEvent() on the window would never see
F10 while the canvas or a panel has focus.
tests/qttest/tst_menubarkeyboard.cpp covers three things: that Alt and a
letter opens a menu (the control), that plain F10 does nothing in Qt itself
(which is why the shortcut exists, and which will fail loudly if a future Qt
starts handling it), and that the shortcut mechanism opens the bar.
It uses QTest instead of driving a real X server for a specific reason.
xdotool on Xvfb delivers every function key with Alt held: a Qt key logger
shows Key_F10 arriving with modifiers == Qt::AltModifier. --clearmodifiers,
keydown/keyup pairs, --window targeting and flattening the keycode with
xmodmap all made no difference. Two rounds of GUI automation therefore gave
confident, wrong answers about F10 -- first that it was broken, then that
this very fix did not work. QTest posts the event straight to the widget, so
the key arrives as written.
What the test does not cover, since initCommonActions() calls
QETApp::instance() and constructing that pulls in the whole application: it
repeats the wiring rather than driving QETMainWindow. Confirming the real
window responds still needs someone to press F10 in a running QElectroTech.
Verified by breaking it: bound to F11 instead, the test fails. Qt 5 and Qt 6
both build clean, 6/6 tests on each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing the Menu key (or Shift+F10) on a folio produced a bare
Undo/Redo/Cut/Copy/Paste/Delete/Select All menu with almost everything
disabled, instead of the menu a right-click gives.
A keyboard-raised QContextMenuEvent carries no useful position -- Qt does
not aim it at the selection. contextMenuEvent() passed the event to
QGraphicsView first, which handed it to whichever item held focus; that
item answered with its own default menu and accepted the event, so the
early return fired and the folio's menu was never built. Even past that,
the itemAt() lookup below would have used an unrelated point.
A keyboard-raised menu is now built directly rather than offered to the
items first, and aimed at the centre of the selection, or at the middle of
the view when nothing is selected. The mouse path is unchanged.
Measured on the same branch with only this change applied: before, the
menu carried 7 actions, all but one disabled; after, 16, positioned on the
selected element. Builds clean on Qt 5 and Qt 6, tests 5/5 on both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nine "Ajouter" actions -- text field, image, PDF, line, rectangle,
ellipse, polyline, curve, terminal strip -- were only ever added to
m_add_item_tool_bar, and the automatic conductor break only to
diagram_tool_bar. None carried a shortcut. A toolbar button has no key,
so someone working without a mouse could not add anything at all to a
folio.
They now appear in an "Ajouter" submenu under Édition, and the conductor
break beside m_auto_conductor in Projet, the setting it pairs with. The
actions themselves are untouched: a QAction can sit in a menu and a
toolbar at once, which is what m_depth_action_group -- created a few lines
away, and added to both its toolbar and menu_edition -- has always done.
That contrast is why this reads as an oversight rather than a decision.
Verified by driving the menus with the keyboard alone under Xvfb: Alt+F
opens the File menu, Down then Right crosses to Édition, and Right again
opens the Ajouter submenu with all eight actions this build compiles
(add_pdf is behind QET_HAS_QTPDF and absent on Qt 5).
Found with tools/keyboard-audit in the qelectrotech-docker harness, which
reports these ten and now reports none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tab cycles the folio's items, which means focusNextPrevChild() has to
refuse the usual focus traversal. On its own that leaves someone working
without a mouse able to reach the drawing area and never leave it -- the
exact person the Tab cycling was added for.
Escape now steps back out in two stages: it drops the selection first,
then hands focus to the next widget. The one-shot m_releasing_focus flag
is what lets that second Escape through the override.
Verified under Xvfb: with an item selected, Escape clears it (193k pixels
change); a second Escape changes nothing visually; a Tab after that moves
widget focus in the toolbar (306 pixels) instead of selecting on the
canvas, which is the behaviour of a view that no longer holds focus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch was cut on 31 July, one day before ShortcutManager landed
in 5275fb44f, so the two actions it adds were written before the
convention existed and are the only members of the selection group not
registered: select_all, select_nothing and select_invert all are.
Without this they never appear in the shortcuts configuration page, so
a user cannot bind a key to either of them.
Registered with an empty default sequence. They are menu actions and
neither has an obvious default worth claiming; the point of registering
them is that a user can bind one if they want. ShortcutManager stores
an empty default without setting a shortcut, and the conflict checker
already skips empty sequences.
Master merged in first, because ShortcutManager does not exist at this
branch's original base.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the second pillar of #574: keyboard-driven selection on the
diagram canvas.
Tab / Shift+Tab select the next / previous item on the current
diagram, cycling through items() (z-order) and wrapping at either
end. If nothing is selected, Tab selects the first item and
Shift+Tab the last. Skipped while a text item has focus, for the
same reason arrow-key movement already guards on !focusItem().
Candidates use the same "what counts as a real selectable diagram
item" filter (QetGraphicsItem / DiagramTextItem / Conductor) already
established by Diagram::invertSelection(), so the cycling order
always matches what a user could reach by clicking.
Getting Tab to actually reach the scene needed two separate fixes,
each independently discovered by empirical testing rather than
assumption:
- QWidget (DiagramView) intercepts Tab/Backtab for widget focus-chain
traversal before generating a key event at all. Overriding
DiagramView::focusNextPrevChild() to return false disables that.
- QGraphicsScene (Diagram) has its own, separate item-focus-chain
traversal, checked before keyPressEvent() is ever reached. The
obvious fix -- overriding Diagram::focusNextPrevChild() the same
way -- silently does nothing on Qt 5, because
QGraphicsScene::focusNextPrevChild() only becomes virtual in Qt 6
(guarded by the QT6_VIRTUAL macro); a compile error surfaced this
immediately when attempted directly, rather than shipping a fix
that worked on Qt 6 and silently no-opped on Qt 5. Intercepting
QEvent::KeyPress in Diagram::event() instead is virtual on every Qt
version and sidesteps the scene's internal traversal entirely.
Also adds Diagram::selectAllConductors() / selectAllTextFields(),
wired up as two new actions in the existing select_all /
select_nothing / select_invert action group in
qetdiagrameditor.cpp, so they appear in the Edit menu and go through
the same QAction -> data() -> selectGroupTriggered() dispatch as the
existing selection commands.
Verified end-to-end in a real running session (Xvfb + xdotool) with
a multi-transistor schematic: Tab/Shift+Tab correctly move a single
selection forward/backward through elements and text fields
(confirmed via the properties panel updating to each new item and
the visual selection box moving on canvas); Tab/Shift+Tab from no
selection correctly select the first/last item; "Select all
conductors" and "Select all text fields" each correctly select every
matching item and deselect everything else.
See discussion #574.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nothing currently builds QElectroTech on Linux in CI, and nothing runs tests/
at all -- the existing workflows build Windows and generate documentation.
Adds one job: configure and build with Qt 6 Debug, run ctest, then run the
IPC open-forwarding regression test.
Verified by running the job's exact step sequence in a clean ubuntu:26.04
container, in both directions:
with the #868 fix 5/5 unit tests pass, gate passes, job green
with the fix reverted 5/5 unit tests pass, gate fails, job red
The unit tests passing in both arms is the point: the existing suite cannot
see this class of bug, which is what the gate is for.
Three choices that are not cosmetic:
- Runs in an ubuntu:26.04 container rather than on the runner. ubuntu-latest
ships Qt 6.4; the gate needs 6.10.2, and whether the crash reproduces on
6.4 has never been checked. A job that cannot go red is worse than none.
- Debug, not Release. The pre-fix commit survives every attempt built
-O3 -DNDEBUG, so a Release job would never catch a regression here.
- An inconclusive gate run warns rather than fails. It means the crash path
was not exercised, which proves nothing and is not the same as a
regression; failing on it would make the job flaky rather than useful.
extra-cmake-modules and the KF6 libraries are installed rather than left to
FetchContent, which otherwise builds ECM from source and fails the configure
demanding Qt6 documentation tools.
Depends on the regression test added in #871.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the crash fixed in #868: a file forwarded from a second instance was
opened inside SingleApplication's socket handler, so the backup prompt's
nested event loop ran while that handler was still on the stack.
Run by hand; no build system or CI changes.
tests/ipc-regression/run.sh --binary build/qelectrotech
Validated in both directions on Qt 6.10.2: ceda1e082 (before the fix) crashes
3 times in 3 with exit 139, 199444b6 (after) survives 3 times in 3.
Three requirements are not obvious and are documented in the script:
- Qt 6 only. An unfixed Qt 5 build survives every attempt, so the script
refuses to run on a Qt 5 binary rather than report a pass that cannot fail.
- A Debug build. The same unfixed commit survives every attempt built
-O3 -DNDEBUG; whether a use-after-free faults depends on what the allocator
does with the freed block.
- Dismissing the backup prompt is the step that triggers it. Left open, the
stack never unwinds and nothing fails, which is why the bug was twice
reported as not reproducible.
The test runs in its own sandbox on its own X display, works on a copy of the
project so backup files do not land in examples/, and cleans up after itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
duplicateDiagram() called restoreText() on every newly loaded
element. Each setPlainText() inside restoreText() is wrapped in
m_block_alignment except the last one, so finishAlignment() ran on
elements whose positions came straight from the XML — shifting
center and right-aligned texts.
Fix: use toXml(true, true) which handles correctTextPos/restoreText
internally for Slave and Report elements only, and call restoreText()
on the target for those same element types to recalculate their text
positions for the actual resolved text.
Two additional bugs found after the initial xref cleanup:
1. Undo after unlink did not restore PLC variables (PLC_TYPE,
PLC_ADDRESS, etc.) because PlcLinkWidget::on_m_unlink_pb_clicked()
never stored the current group index in the LinkElementCommand.
makeLink() only populates PLC fields when group_idx >= 0, so
the slave was re-linked but appeared empty. Fixed by reading the
group index from the master via groupIndexForElement() and calling
setGroupIndex() before unlinking.
2. m_update_slave_Xref_connection was only cleared inside the
if(m_slave_Xref_item) block in the updateXref() cleanup path.
For AlignHCenter (Text field) position, no m_slave_Xref_item
is ever created — the connections are stored in
m_update_slave_Xref_connection but never cleaned up on unlink.
On re-link, the old stale entries prevented new connections from
being established. Fixed by clearing the list unconditionally
in the cleanup path.
The comment sat one tab deeper than the code around it. Flagged in
review on PR #868. Whitespace only; no change to behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QETApp::receiveMessage() called openFiles() directly. That slot runs inside
SingleApplication's socket handling: SingleApplicationPrivate::
slotDataAvailable() emits receivedMessage synchronously from the readyRead
lambda (singleapplication_p.cpp:452). openFiles() then loads a project --
seconds of work on a large one -- and openAndAddProject() puts up a modal
BackupDialog whose exec() runs a nested event loop while the socket handler
is still on the stack.
During that nested loop the secondary instance exits, the connection closes
and the QLocalSocket is deleted. When the dialog is dismissed and the stack
unwinds, QMetaObject::activate() carries on emitting on the freed sender and
the process dies.
A zero-timer returns to the event loop first, so the socket stack is fully
unwound before any project is opened.
Found by scorpio810 while testing PR #861, with a backtrace showing no QET
frame above the crash. His second suggestion, looking for a delete that
should be deleteLater(), turned out to be already satisfied at
singleapplication_p.cpp:331 -- which is why the deferred delete is not enough
on its own once a nested loop is in play.
Dismissing the dialog is the step that makes it fail: two earlier attempts to
reproduce it left the dialog open, the stack never unwound, and nothing
crashed. With the dialog dismissed it segfaults twice out of two; with this
change it survives twice out of two, opens the project as before, and ctest
stays green. Qt 6.10.2 on X11/xcb -- also checked under a headless Wayland
compositor and under Qt 5.15.18, so it is neither Wayland-specific nor a Qt6
regression.
The crash needs PR #861 to be reachable at all: without it splitWithSpaces()
returns an empty list, no project opens, and nothing enters this path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SummaryQueryWidget::queryStr() built its ORDER BY from the columns the user
chose to display, in the order they chose them:
column += key;
order_by += key;
So a summary whose first column is Title came out sorted alphabetically by
title, and one starting with Author sorted by author. A table of contents
lists the folios of a project; its order is the project's order, not
whatever the first column happens to be.
It now orders by "pos", the folio position that project_summary_view already
exposes from diagram.pos. That column is an INTEGER, so the sort is numeric
and folio 10 does not land between folio 1 and folio 2. One row per folio
means pos fully determines the order, so no secondary key is needed.
Demonstrated against a stand-in view holding four folios:
ORDER BY title, pos Apple(2) Banana(3) Mango(10) Zebra(1)
ORDER BY pos Zebra(1) Apple(2) Banana(3) Mango(10)
The hand-written query path (m_edit_sql_query_cb) returns before this and is
untouched, so anyone wanting a different order still has one.
ctest 4/4, Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The File > Recently-opened submenu was filled once, at editor construction,
by copying the QActions that RecentFiles' menu happened to hold at that
moment:
recentfile->addActions(QETApp::projectsRecentFiles()->menu()->actions());
RecentFiles::buildMenu() runs on every fileWasOpened(), clears its menu and
creates fresh QActions. The editor's copy therefore never gained an entry,
and the list only ever looked correct after a restart.
The submenu is now the RecentFiles menu itself. QMenu::addMenu() adds the
submenu's menuAction() rather than reparenting it, so several editor windows
can share the one live menu, which is what an application-wide recent-files
list should do anyway.
Measured with a temporary probe comparing the live menu against what the
File menu actually shows, after one file had been opened in the same
session:
without the fix live=1 shownInFileMenu=0
with the fix live=1 shownInFileMenu=1
ctest 4/4, GUI starts clean with the menu bar intact. Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conductor::paint() drew every junction as a fixed 3.0-unit ellipse,
regardless of how wide the conductor carrying it is. The conductor width is
user-settable from 0.4 to 20.0, so at anything above about 3.0 the dot is
narrower than the line it sits on and disappears entirely -- exactly when a
junction most needs to be legible.
The dot now scales with m_properties.cond_size, floored at the historic 3.0
so nothing changes at or below the default width of 1.0. Only the wide
conductors the report is about are affected.
cond_size is used rather than the pen width because the pen is inflated by 4
while the mouse is over the conductor; the junction should not grow on
hover.
Measured with a temporary trace over examples/741.qet: at the default width
the diameter stays 3.00, and with condsize="5" it becomes 15.00. Visually,
a PNG export of that widened project shows two junctions that were invisible
under the line rendering as clear dots. ctest 4/4, Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QET::splitWithSpaces() split on QRegularExpression("[^\\]?(?:\\\\)* ").
That is not a valid pattern: "[^\\]" opens a character class whose "\\]" is
an escaped bracket, so the class is never closed. QRegularExpression
reported isValid() == false, QString::split() warned "invalid
QRegularExpression object", and the function returned an EMPTY list for
every input.
It is the receiving half of the SingleApplication handshake: a secondary
instance sends "launched-with-args: " + joinWithSpaces(args) (main.cpp) and
the running instance parses it in QETApp::receiveMessage() before calling
openFiles(). With the split always empty, the running instance received no
arguments at all -- so opening a project while QET was already running
silently did nothing.
The bug is reported against filenames containing spaces, which is how it
was noticed, but it is not limited to them: plain names failed identically.
A corrected regex is not available. The separator is a space preceded by an
even-length run of backslashes, and PCRE2 has no variable-length lookbehind,
so the run cannot be expressed in a lookbehind and anything that matches it
by consumption eats the character before the space -- which is what the
"[^\\]?" was for. Scanning the string explicitly is correct and easier to
read.
tests/qttest/tst_qetstrings.cpp asserts the round trip
splitWithSpaces(joinWithSpaces(x)) == x over plain names, embedded spaces,
embedded backslashes, a trailing backslash and a mixture, plus the specific
regression that a plain argument list does not come back empty.
Verified the test fails without the fix: 9 of 11 cases fail on the old
implementation and all 11 pass with it. Full suite 5/5, Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forum #3186 / issue #850: a user who has built up conductor and element
numbering rules in one project has no way to reuse them in the next one.
The only answer today is to open both .qet files in a text editor and copy
the XML across by hand.
Adds an "Import from another project..." button to the auto-numbering page
of the project properties dialog. It offers every numbering found in the
chosen file, per category, with names that already exist here unticked by
default and a "replace same-named numberings" option for when that is what
the user wants.
The source file is parsed as plain XML rather than opened as a QETProject.
Opening it would run the whole load path, including the modal dialog raised
for a file written by a different version of QElectroTech -- a dialog the
user has no reason to see, since nothing but the <newdiagrams> block is
being read.
Two supporting changes:
- readValuesFromProject() clears the three combo boxes before filling
them. It only ran once before; it now runs again after an import, and
without the clear every name appeared twice.
- FolioAutonumberingW::setContext() likewise replaces its list instead
of appending to it. It has a single caller, the line above.
This deliberately does not attempt the project-template feature also raised
on the forum thread. That needs decisions about where templates live and
what else they carry, and is better settled in a discussion first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A slave and a terminal are both routinely separately orderable hardware. A
circuit breaker can carry ten or twenty auxiliary blocks, each with its own
order code, and a terminal block is a purchased part in its own right.
Neither was reaching the bill of materials.
Decided in discussion #847: @IBSYSLevi -- "I would not expect that a defined
piece of hardware is excluded from BOM when not specifically defined as so" --
with use cases from @jozi332 covering Siemens breakers with ten to twenty
auxiliary blocks and PLC cards carrying per-channel data.
Two filters had to change, which is easy to miss: BomExport::defaultQuery()
and, upstream of it, the WHERE clause of element_nomenclature_view itself.
Changing only the query does nothing for slaves, because the view had already
removed them. Terminals were already in the view, so they appeared as soon as
the query allowed them -- which made a half-finished change look like it had
worked.
Measured on examples/industrial.qet, which holds 96 terminals and 41 slaves:
258 rows before, 354 with terminals, 395 with both. A slave given a
manufacturer and part number now appears in the export; previously it could
not, at any setting.
Nothing that should stay out of a bill of materials is newly included. The
folio report arrows and the conductor definition are still excluded because
they are not hardware, and anything else -- a relay's own auxiliary contact,
which is not orderable separately -- is kept out with exclude_from_bom, which
the view already honours and which #721 and #765 made settable on the symbol
itself.
tst_smart_device is updated rather than weakened. @enesgursoy6110 wrote it in
#830 to prove the filter works, inserting rows designated "Must not be
exported"; the slave and terminal rows now carry real designations and are
asserted present, and a folio report arrow takes over as the negative case,
so the test still proves filtering happens -- at the boundary we now want.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifth site of the hash-ordering defect fixed in #844.
TitleBlockTemplatesProjectCollection::templates() returns
titleblock_templates_xml_.keys(), a QHash, and QETProject::toXml() iterated
it directly. A project embedding more than one template therefore wrote the
<titleblocktemplate> children in a different order on every save.
examples/affuteuse_250h.qet embeds three -- A4_1, DIN_A4 and DIN_A4_copy --
and two saves of it produced "DIN_A4 A4_1 DIN_A4_copy" and
"DIN_A4_copy A4_1 DIN_A4". It was the last of the two projects #844 could not
make reproducible.
Worth recording because the first reading of that diff was wrong: seeing
name="DIN_A4" on one side and name="DIN_A4_copy" on the other looked like the
save path renaming a template, which would have been far more serious -- a
diagram referring to it by name would have been left dangling. The file
simply contains both, and they had swapped places.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts merge commit 3d5799773, restoring shortcutsconfigpage.cpp to
its state before it.
#759 and #821 fix the same issue (#757). #821 was opened on 8 September and
is the better fix; #759 was merged on 12 September without checking whether a
PR for it already existed, and its merge is what left #821 conflicting with
master. Reverting is the way to let the right change land.
#759 keys conflict detection on the row's category, which is a tr() string.
#821 keys on the shortcut ID prefix, which is stable and untranslated, and
encodes the overlaps the category cannot express: main-window actions are
live while any editor is open, and the depth.* actions are installed into
both the diagram and the element editor.
Checked against the registry rather than by reading -- 94 registered actions
plus the four depth.* ones registered through QObject::tr. On the shipped
defaults the two behave identically: all 24 shared sequences are legitimate
cross-editor duplicates and neither flags them. They diverge on shortcuts a
user assigns, where #759 misses four classes of real conflict that #821
catches: a diagram or element editor action given the main window's F1, and
a diagram or element action given a depth.* sequence.
The reason #759 looked adequate is that the scope prefix currently maps
one-to-one onto the translated category for all seven scopes, so same-scope
detection comes out the same either way. It fails only where scopes overlap,
which is the case #821 exists to handle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth and last instance of the ordering defect, and the inner half of the
one fixed in the previous commit. ProjectDBModel::toXml() builds each
section's role list from m_header_data.value(key).keys(), and m_header_data
is a QHash<int, QHash<int, QVariant>> -- so both levels are randomised per
process. Sorting the sections left the roles inside each section still
arriving shuffled, which showed up as <data> children with the same
section="0" swapping places between two saves.
With this, save idempotence across the shipped examples goes from 6 of 23 to
22 of 24.
The two that remain fail for unrelated reasons, not for ordering:
schema_indus.qet stores no uuid attribute on its elements at all, so
fromXml() invents a fresh one on every load; and affuteuse_250h.qet loses a
title block logo's storage attribute and renames a title block template on
save. Both are separate defects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third instance of the ordering defect the two previous commits fixed, and
the one that was still making five of the shipped examples save
irreproducibly after those: QETXML::modelHeaderDataToXml() iterates
data_hash.keys() directly, and data_hash is a QHash<int, QList<int>> whose
key order is randomised per process. The <data> children of <header_data>
therefore came out in a different order on every save, which is what a
diff of two saves of industrial.qet showed -- the same EditRole, FontRole
and TextAlignmentRole entries, shuffled.
Sorting the section list fixes it. The roles within a section are a QList
and were already written in a stable order, so they are left alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same defect as the <xref> ordering fixed in the previous commit, in the same
function and left behind by it: conductorAutoNum(), folioAutoNum() and
elementAutoNum() are QHash, whose key order is randomised per process, and
all three were iterated directly. A project holding more than one scheme in
any of the three categories therefore wrote those children in a different
order on every save, so opening and saving without an edit produced a file
that differed from the original, and differed again next time.
Three of the shipped examples are affected: Projet_vierge.qet has 8 conductor
schemes, industrial.qet has 4 element and 2 folio schemes, and
tableau_domestique.qet has 2 element schemes.
Sorting the key list is the same remedy already applied to the xrefs, and
changes nothing else: the same children are written, with the same contents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diagram::toXml() sorts elements by position alone. That is not a total
order: two elements can sit at the same x/y. lmdg.qet has a pair of
text elements both at 780,350, their sort keys are identical, and
std::stable_sort then falls back to the order QGraphicsScene handed us,
which varies between runs. The two swapped places on every save.
Appending the uuid gives a total order. This keeps the reasoning in the
existing comment intact rather than contradicting it: that comment warns
against sorting *by* uuid, because an element with no persisted uuid
attribute is given a fresh random one by fromXml() on every load. As a
tiebreaker the uuid is only consulted when two positions are equal, so
elements carrying a persisted uuid -- the colliding pair in lmdg.qet
included -- become deterministic, and a collision between two legacy
elements is no better ordered than before, but no worse.
Measured with tests/determinism, on top of the xref ordering fix:
before both fixes I1 0/23
xref ordering only I1 5/23
with this as well I1 6/23 (lmdg.qet newly reproducible)
No regressions against the baseline, I3 stays 23/23. Also checked
lmdg.qet directly three times rather than once, since the failure is
nondeterministic by nature and a single passing run proves nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QETProject::toXml() iterated defaultXRefProperties().keys() straight into
the document. That is a QHash, and Qt randomises hash iteration order per
process, so every save wrote the <xref> children in a different sequence.
Saving an unchanged project therefore produced a different file each
time. The content was identical -- same size, same elements -- but the
order moved, so version control showed spurious changes on every save and
comparing two saved files showed differences that were not there.
Sorting the keys before writing makes a save reproducible. This is the
same class of problem, and the same fix, as the sort already applied to
Diagram::toXml()'s <elements> and <conductors> blocks.
Measured with tests/determinism (resave twice, compare):
before: I1 idempotent save 0/23
after: I1 idempotent save 5/23
with ArduinoLCD, ShellyParts, convertisseur, schema_indus and
schema_unifilaire_voltaique2 newly reproducible, and no regressions
against the baseline.
Not the only remaining source of save instability -- the other 18
projects still fail I1 for other reasons. This fixes the hash-ordering
source only.
Note this is not a Qt6 regression. The Qt5 build happened to produce a
favourable hash order for four projects and Qt6 does not, but both were
writing an unspecified order; only the dice changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making the Informations tab visible for Slave elements is only half the
change: ElementScene::toXml() writes the <elementInformations> block for
Simple, Master, Terminal and Thumbnail, and Slave was not in that list. It
is the only place in the tree that writes that block, so the editor would
have shown an editable tab for a slave, accepted whatever the user typed
into it, and dropped it silently on save.
Visible in the shipped collection, which matches the condition exactly:
0 of 75 slave elements carry an <elementInformations> block, against 41 of
70 terminal elements.
Adding Slave is safe in both directions. ElementData::fromXml() reads
<elementInformations> unconditionally, with no check on the base type, so
existing slave elements are unaffected and newly written ones load back
correctly. It also makes populateTree()'s PLC-slave branch reachable for
the first time -- the five PLC info rows it adds are stored in
m_informations, so until now they could not have been saved either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
exportBomCsv() calls query.value(i).toString(). QSqlQuery::value() returns
a QVariant, but the translation unit only ever sees the forward declaration
that arrives through qobject.h, so the call does not compile:
sources/bomexport.cpp:79:50: error: invalid use of incomplete type
'class QVariant'
79 | values.append(query.value(i).toString());
Reproduced on a clean checkout of master with Qt 5.15.18. Qt6 pulls the
full definition in by another path, so the Windows CI workflow does not
see it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N64mk33R9GdbU1PkYcc9SP
When a placed nomenclature or summary table cannot display every row its
model holds, checkInsufficientRowsCount() informs the user with a modal
message box. It used QMessageBox directly rather than QET::QetMessageBox,
so it ignored the non-interactive mode that main.cpp sets for the
command-line verbs, and every headless verb (--info, --resave, --export-*)
blocked forever on a dialog nobody could answer.
This is the same defect fixed in e3d11a499 for the other modals reachable
from the command line; this call site was missed.
Found with a gdb backtrace on a hung --info: the process was parked in
QDialog::exec() under this function.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6MRq2Ach1ogvnGcbuqNLr
ElementPictureFactory caches the QPicture it builds for an element
definition, keyed by that definition's uuid. Definitions saved before uuids
were written do not have one, and every one of them presented the same null
uuid. getPictures() spotted that and took an uncached path, so the drawing
was rebuilt from the XML for every instance the project placed.
Counted on the shipped examples:
examples/m_000.qet 831 builds for 97 definitions
examples/affuteuse_250h.qet 256 builds for 106 definitions
examples/industrial.qet 65 builds, 553 cache hits (has uuids)
13 of the 23 example projects carry definitions without a uuid, so this is
not a rare shape.
Derive a key from the location when the definition has no uuid of its own.
ElementsLocation::toString() qualifies an embedded path with the id of the
project owning it, and QETApp hands out project ids from an ever-increasing
counter and never reuses them, so the derived key cannot collide with an
element of another project.
Measured with callgrind, which counts instructions and so does not depend
on what else the machine is doing, opening examples/affuteuse_250h.qet:
4,801,381,735 -> 4,285,411,908 instructions (-10.7 %)
ElementPictureFactory::build 995 M -> 478 M
ElementPictureFactory::getPictures 1289 M -> 774 M
The halving of build() matches the counters independently: 106 definitions
against 256 instances is 41 %, and the cost falls to 48 %.
This also retires a latent aliasing bug rather than a measured one:
build() inserted into m_primitives_H under the same null uuid for every
definition lacking one, and getPrimitives() read back through that shared
key. Its only caller is the image export dialog, which the command line
does not reach, so no wrong output could be demonstrated here -- but the
entries could only ever have belonged to whichever element was built last.
--info stays byte identical on all 23 example projects, and the SVG export
of affuteuse_250h.qet -- a project whose definitions all lack uuids -- is
byte identical too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6MRq2Ach1ogvnGcbuqNLr
setPainterStyle() built its QRegularExpression as a local, so the pattern
was compiled from scratch on every call -- and it is called for every
graphics primitive of every element instance a project places. A callgrind
profile of opening examples/affuteuse_250h.qet put 28 % of all instructions
inside libpcre2, and 12 % of the whole run inside this one function.
Making it static const compiles the pattern once for the life of the
process. Nothing else changes: same pattern, same matching, same named
captures.
Measured with callgrind, which counts instructions and so does not depend
on what else the machine is doing, opening examples/affuteuse_250h.qet:
4,801,381,735 -> 4,297,629,948 instructions (-10.5 %)
setPainterStyle 582 M (12.13 %) -> 79 M (1.83 %)
--info stays byte identical on all 23 example projects, and so does every
SVG this produces for industrial.qet -- which is the output that would
change if the styles were parsed any differently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N64mk33R9GdbU1PkYcc9SP
Destroying a project cost more than loading it: on a 1000 folio project
--info reported its work done in 160 s but the process ran for 657 s, and
the difference was ~QETProject().
Timing each destructor puts 94 % of that teardown in
~QetGraphicsTableItem(), with the cost per table doubling as the project
grows (48 ms at 100 folios, 122 ms at 250). The database's own per-element
deletes are 1 % of it and linear; element and conductor teardown is linear.
A table destructor repairs the chain it belonged to, which relinks the
neighbouring tables, which assigns a model -- and one branch of
setPreviousTable() builds a fresh ProjectDBModel, whose copy constructor
calls setQuery(), which rebuilds the whole database. Destroying a 250 folio
project did that 12 times, for a project that is being thrown away.
So block the rebuild for the lifetime of the destructor, next to the
blockSignals(true) already there for the same reason. Nothing can observe
the result: the database is destroyed moments later as a member of the
project. Teardown drops about fivefold at every size measured -- 1.30 s to
0.26 s at 100 folios, 7.75 s to 1.73 s at 250, 19.92 s to 4.02 s at 400 --
and the number of full rebuilds in a run stops growing with project size.
Teardown is still superlinear, now dominated by
QetGraphicsTableItem::setUpColumnAndRowMinimumSize() measuring every cell of
the nomenclature each time a chain is relinked. That is left alone here.
--info stays byte identical on all 23 example projects, as do --export-bom,
--export-wires, --export-cables, --export-nets and --export-wiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6MRq2Ach1ogvnGcbuqNLr
ProjectDBModel::setQuery() calls projectDataBase::updateDB(), which drops
and repopulates every table in the database. The rebuild does not depend on
the query, so each table model that queries the database while a project is
being read triggers another complete repopulate of the same content.
Opening a 100 folio project ran updateDB() 26 times, 9.9 s of a 15.7 s load.
Two changes, because the first alone is not enough:
setUpdateBlocked() lets a bulk operation suppress the rebuild and do it
once when it is done. readProjectXml() already wrapped the load in
blockSignals(true) "to avoid hundreds of unnecessary emitted signal", but
that suppresses only the signal, not the work it announces; this extends
the same intent to the work. Both early returns in readProjectXml() sit
before the block, so no path leaves the database permanently blocked.
Further rebuilds are triggered after readProjectXml() returns, where the
load phase timers cannot see them -- with only the block in place
updateDB() still ran 5 times on examples/industrial.qet. So the database
now also tracks whether anything has changed since the last rebuild, and
skips repopulating when nothing has. dataBaseUpdated() is still emitted in
that case: callers and models rely on it to refresh, and what they read
back is the same either way. Every method of the class that writes rows
marks the flag; from outside, the database is reachable only through
newQuery(), and all five call sites read.
Repeating the rebuild was wasteful rather than wrong -- each
populate*Table() begins with a DELETE -- so this changes no output.
Verified byte identical --info on all 23 example projects, and identical
--export-bom, --export-wires, --export-cables, --export-nets and
--export-wiring on industrial.qet. Its load drops from 5.51 s to 5.31 s
(median of 6).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6MRq2Ach1ogvnGcbuqNLr
The remaining two defects from bugtracker #671's original analysis,
which #686 knowingly didn't cover (see that PR's review thread and the
comment on the now-closed #672).
## #671 item 5: the XML matching ignored nesting
prefixFromLabelFile() was a flat token scan: it matched any <category
name="..."> whose name equalled the next path segment, with no check
that the match was actually a *child* of the previous match. It gave
correct results on the shipped 10_electric/qet_labels.xml only because
that file's document order happens to line up with its hierarchy --
any file with a same-named category at the wrong nesting depth would
silently return the wrong prefix.
Reproduced with a synthetic file where a top-level sibling category
happens to share a name with what should be an unmatched grandchild:
the old (already re-verified-fixed-for-whitespace) lookup returns a
prefix from a completely unrelated branch of the document; this
rewrite correctly reports "not found".
Fixed by replacing the QXmlStreamReader token walk with a QDomDocument
walk that only ever considers a matched node's direct <category>
children (firstChildElement()/nextSiblingElement(), scoped to that
node), which cannot cross into a same-named sibling subtree. This also
makes the whitespace-dependence fixed in #686 moot for the same
reason: DOM parsing doesn't distinguish pretty-printed from minified
input to begin with.
The inheritance rule ("if a directory has no prefix, use its parent's,
and so on") and the empty-<prefix/>-overrides-inheritance behaviour
#686 added both carry over unchanged: a category's own <prefix> child,
even an empty one, always overrides whatever a shallower ancestor
already provided; a category with no <prefix> child at all leaves the
inherited value untouched.
## #671 item 2: common-collection trees other than 10_electric
The lookup only ever consulted commonElementsDir()/10_electric --
literally: `if (current_location.fileName() == "10_electric")`. The
common collection ships four other top-level trees (20_logic,
30_hydraulic, 50_pneumatic, 60_energy); none of them could carry a
qet_labels.xml at all, because nothing ever looked for one.
Generalised to commonElementsDir()/<tree>/qet_labels.xml for whichever
top-level tree the element's path actually walks up to, tried first,
then custom, then company -- each of the latter two tried against both
a from-root layout (matching a custom/company file organised as a
mirror of the common collection, tree name included) and a
tree-relative one (matching a file scoped to just one tree), so
existing custom files keep working either way. This is the same
multi-candidate structure #686 already established for custom-then-
company; it now also covers which common-collection tree to check.
## Testing
Same constraint as #686: no working full build in this sandbox
(missing generated headers/deps), so the exact functions as committed
were extracted into a standalone Qt6 harness and run against the real
shipped 10_electric/qet_labels.xml (pretty-printed and minified),
a synthetic empty-prefix-override file, and the nesting-trap file
above -- 9/9, including the three cases #686 already fixed (direct
prefix, inherited prefix, not-found) staying correct, confirming this
rewrite doesn't regress that work.
Not exercised here (needs a real running QETApp / ElementsLocation,
which the standalone harness can't stand up): the elementPrefixForLocation()
candidate-list wiring itself -- collection_root computation, the
from-root/tree-relative dual lookup, and the common-then-custom-then-
company ordering. That code is mechanical and was reviewed carefully
by hand, but it has not been run.
Requested by @scorpio810 in review: an empty <prefix/> in the custom
collection should cancel a company-collection prefix, not fall through
to it. QXmlStreamReader::readElementText() returns a null QString for an
empty element, and the caller's isNull() check treats that the same as
"not found" -- distinguish the two so an explicit override actually
overrides. Verified in a standalone harness against a synthetic
override file, pretty-printed and minified.
Two more while in the same function, both from the original bugtracker
#671 analysis that this PR only partially addressed:
- QString path[10] with an unbounded index becomes a QStringList. The
deepest category in the shipped collection already needs 9 of the 10
slots; a custom collection can nest deeper, and overflow was writing
QString objects past the end of a stack array (#671 item 3).
- The common-collection lookup still concatenated
commonElementsDir() + "10_electric/qet_labels.xml" directly.
commonElementsDir() returns the configured path verbatim with no
guaranteed trailing separator, so relocating the collection to a path
without one silently mangles this into one word and the file is never
found -- the single most-reported cause of "prefixes don't work"
(#671 item 1, forum #2178/#2651). QDir::filePath() joins correctly
either way; applied to all three lookups (common, custom, company).
Also fixes a defect not in that original analysis: the token-matching
loop in prefixFromLabelFile() advanced twice per matched element --
once explicitly after a match, once more unconditionally at the bottom
of the loop -- which only produced the right result because a
pretty-printed file inserts a whitespace Characters token between
adjacent elements for the second advance to land on. A minified
qet_labels.xml has no such token, so the second advance skips clean
over the very element being searched for and the lookup silently finds
nothing -- reproduced against the real shipped 10_electric/qet_labels.xml
(returns "" instead of "K" for a plain coil, on every case tested, not
just the inheritance one). A single `continue` after a handled match
removes the double advance.
Testing: extracted the exact functions as committed into a standalone
Qt6 harness (outside the full QET build, which needs a dependency
fetch this sandbox doesn't have) and ran them against the real shipped
qet_labels.xml, pretty-printed and minified, covering a direct prefix,
inherited-from-ancestor prefix, not-found, and the explicit-empty-
override case -- 8/8, matching between formats, no regressions in the
pretty-printed results. The QDir::filePath() fix was verified
separately against both a trailing-slash and no-trailing-slash base
path. Not yet built inside the actual application (pugixml and other
generated headers aren't available standalone); the algorithm itself,
which is where all four defects lived, is what was under test.
A .qm compiled from a 0%-translated .ts (fi, no, rs, sk, sl, sr) loads
successfully but contains no messages, so setLanguage() treated the
language as loaded and never fell back to qet_en: users got the French
source strings instead of English. Treat an empty translator as not
loaded.
Also log the QET and Qt .qm files actually loaded in the startup
diagnostics (MachineInfo), to make translation reports easier to triage.
windeployqt runs with --no-translations, so standard buttons (OK/Cancel)
and dialogs stayed in English. Copy each qtbase_XX.qm from the MSYS2 Qt
translations into files/lang/qt_XX.qm, where QETApp::setLanguage() looks,
with aliases for QET languages Qt only ships with a region (pt, zh).
Standard buttons (OK/Cancel) and dialogs are translated by qtbase_XX.qm,
which macdeployqt does not deploy. QETApp::setLanguage() falls back to
lang/qt_XX.qm, so copy each qtbase_XX.qm there, with aliases for QET
languages Qt only ships with a region (pt -> pt_PT, zh -> zh_CN).
macdeployqt kept /opt/homebrew paths (e.g. libbrotlicommon's install id).
Rewrite them to @rpath/libX.dylib, copying the library into Frameworks
if needed, over 3 passes to handle chained dependencies.
Recent Homebrew bottles (brotli, webp, sharpyuv) reference their deps as
@rpath/libX.dylib, which macdeployqt skips. Copy them from /opt/homebrew/lib
into Contents/Frameworks after macdeployqt, and abort if any @rpath or
/opt/homebrew reference remains unresolved. Drop the ineffective -libpath.
polluting the source directory during out-of-source builds and it is standard that these files should be located inside the build directory during the build step.
Second of @scorpio810's review notes on #630:
exportWiring() follows the existing CLI exporters (QTextStream, plain
QFile). On Qt6 the output is UTF-8, so encoding is fine. Once #830 is
in, it could optionally reuse BomExport::writeCsv() to get a BOM,
which Excel needs to detect UTF-8 when opening the file directly, and
an atomic write.
Done directly rather than waiting on #830, since neither half depends on
it and both are small.
The bytes were already UTF-8; what was missing is the mark that tells
Excel so. Opening a .csv without one, Excel falls back to the local
8-bit codepage and mangles any accented element label -- the common case
for this project's users.
QSaveFile replaces QFile so a failure part-way through leaves the
previous file intact instead of a truncated one. QSaveFile is already the
codebase's pattern for this (QET::writeToFile, qet.cpp:664).
Verified on perceuse.qet: output now starts ef bb bf, the header follows
intact, all 156 rows are preserved, and the file parses as utf-8-sig.
Pointing the exporter at a missing project leaves an existing target file
untouched, where before it would have been truncated.
Left the other CLI exporters alone. They share the same pattern, but
changing exportBom() would add a BOM to output that existing scripts
already consume, which is a behaviour change outside the scope of this
review note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows @scorpio810's review note on merging #630:
ORDER BY diagram_position, wire_number sorts wire numbers as text,
so "10" comes before "9".
Confirmed against the corpus: perceuse.qet put 111 before 12, and
affuteuse_250h.qet put 45 before 5. industrial.qet happened to look
correct only because its wire numbers are all the same width.
Wire numbers are free text and are not always numeric -- perceuse.qet
also carries an unresolved "%sequ_1" -- so the ordering has to cope with
both. Numeric values come first, ordered by value; anything else follows,
ordered as text. The trailing wire_number keeps ties stable.
Fixed in both places the query appears: the CLI exporter and the wiring
list dialog. They had the same ORDER BY, so fixing only one would have
made the dialog and --export-wiring disagree about the order of the same
data.
Verified on perceuse, affuteuse_250h, industrial and tremie_vibrante:
zero out-of-order numeric pairs afterwards, row counts unchanged, and
"%sequ_1" now sorts after the numbers rather than among them. Folio 3 of
perceuse.qet reads 0 1 2 3 4 4 5 5 6 6 7 7 12 12 where it previously
interleaved 111 before 12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qt6 PrintSupport records Cups::Cups as a third-party dependency
(qprint_p.h includes <cups/ppd.h>), so find_package(Qt6 PrintSupport)
runs FindCups at configure time and fails without the CUPS headers.
Build-time only, nothing is staged.
#824 read the pixmap through the pointer overload, which Qt 5.15
deprecates, so the fix it introduced compiled with two deprecation
warnings of its own. Qt 5.15 offers the by-value form behind
Qt::ReturnByValue, so both branches can take the same overload and the
difference reduces to the argument.
Equivalent: the pointer overload returns nullptr when no pixmap is set,
which the old expression turned into a null QPixmap; pixmap(
Qt::ReturnByValue) returns a null QPixmap directly. It also drops the
null check, so the Qt5 branch is now a single expression.
Verified both arms of the #if, since a preprocessor-branched change is
only half tested otherwise:
- Qt 5.15.18: deprecation warnings for this file 2 -> 0, builds clean,
binary runs
- Qt 6.10.2: builds clean, 488/488, links
- 22 example projects load and export with no crash or hang
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
max_slaves records how many contacts a part is expected to carry. It was
enforced as a rule the drawing had to obey, which obstructs the way both
@scorpio810 and @IBSYSLevi described working in #819: draw the schematic
first, choose the physical hardware afterwards. A limit that refuses the
link forces the hardware decision up front, which is exactly what they
said gets in the way.
Two changes, both in the UI rather than in isFull(), which stays the
query it always was:
- MasterPropertiesWidget::on_link_button_clicked() now says the limit
is reached and asks whether to link anyway, defaulting to yes,
instead of refusing outright.
- LinkSingleElementWidget no longer removes a full master from the
candidate list. That was the worse half: a master at its limit simply
was not there, indistinguishable from one that does not exist, with
nothing to say why. It now stays selectable and the user decides.
PLC masters are deliberately left alone. Their limit is the number of
declared IO slots, which is structural rather than advisory -- a link
past it would have no IO index to map to -- and PlcLinkWidget already
tells the user when it hides one, via m_hidden_masters_label.
Only coils that opt into a limit are affected: max_slaves defaults to
-1, and no project in examples/ sets it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lang1/ was a leftover from the pre-Qt6 translation pipeline.
Qt6/CMake now produces all .qm files directly into lang/, which
is already copied above, making this step dead code.
CFBundleIdentifier was "org.qelectrotech", but Qt derives
"org.qelectrotech.QElectroTech" from setOrganizationDomain()
and setApplicationName() for the app's own preferences file
(~/Library/Preferences/org.qelectrotech.QElectroTech.plist).
Align the two so the shipped bundle and the CMake target (see
CMakeLists.txt MACOSX_BUNDLE_GUI_IDENTIFIER) use the same
identifier regardless of build path.
Note: this changes the bundle's LaunchServices identity, so
users may need to redo "Open With QElectroTech" file
associations once after updating.
CMakeLists.txt marks the macOS target as MACOSX_BUNDLE but never sets
MACOSX_BUNDLE_GUI_IDENTIFIER, so CMake's default Info.plist template
substitutes an empty string for CFBundleIdentifier.
An .app with an empty identifier is never registered by LaunchServices
(`lsappinfo info` reports bundleID="" and bundle path=[NULL]). AppKit
runs the open/save panel in an XPC service keyed on the client's bundle
identifier: the service is spawned on each request but presents no
window, so QFileDialog::getOpenFileName() and getSaveFileName() return
an empty string without a panel ever appearing. In QET this means
File > Open and File > Save as silently do nothing -- openProject()
receives an empty path and returns at its `if (filepath.isEmpty())`
guard. Every macOS CMake build has been affected since the target
became a bundle.
Fill in the identifier along with the other bundle metadata CMake's
template expects. org.qelectrotech.QElectroTech is the identifier Qt
already derives from setOrganizationDomain("qelectrotech.org") and
setApplicationName("QElectroTech") for the app's own preferences file,
so the bundle now agrees with what the app writes at runtime.
Verified on macOS 27 with Qt 6.11: before the change File > Open and
File > Save as present nothing; after it both panels open normally. No
code signing step is needed -- the linker's ad-hoc signature still
reports the executable name as its identifier, and the panels work
regardless once the plist is correct.
An element can declare contact groups and a max_slaves that disagree with
each other, and nothing reconciles them.
The element editor keeps the two in step: max_slaves sizes the contact
group table, one row per slot. Nothing does so on load, so a hand
written or generated file can carry five groups and max_slaves=2. That
loads without complaint, isFull() then caps linking at two, and
ContactGroupSelectionDialog still offers all five groups -- so the user
is shown groups that cannot be linked to, with nothing to explain why.
When groups are declared they are the slots: a slave occupies exactly
one, and the selection dialog offers exactly these. So take the limit
from the group count, which is also the number the user can see.
max_slaves stays as the fallback for the elements that declare no
groups, which today is every element in the standard collection.
No element in the collection declares contact groups, so this changes
nothing for existing projects.
Verified with two purpose-built fixtures, since no real element
exercises either path: a coil declaring five groups with max_slaves=2
now takes the limit from the groups, and a coil with max_slaves and no
groups still takes the fallback path unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two units were being stacked in the same block. The line above reports
max_slaves, which is a number of slots, so reporting the line below in
contacts made a coil with one 4 pole slave read "maximum 4 / used 4"
while three slots were still free.
The total goes back to counting linked elements, matching the unit of
the line above it and restoring the original behaviour of that line.
The per-type breakdown keeps the pole multiplier, because that is the
question it answers -- how many contacts an auxiliary block must
provide -- and is now prefixed "Contacts :" so the two units are not
mistaken for each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The earlier commit changed MasterElement::isFull() to compare the
contacts in use against max_slaves. That was wrong, and this restores
the original comparison against the number of linked elements.
max_slaves is a number of slots, not of contacts:
- it sizes the contact group table in the element editor, one row per
slot (ElementPropertiesEditorWidget::populateSlaveGroupsTable)
- a group must match the slave's own contact count before it can be
chosen, so a 4 pole slave needs a group declaring 4 and occupies
that single group (ContactGroupSelectionDialog)
- each slave stores exactly one group index
(Element::setGroupIndexForElement)
So a coil declaring 4 slots accepts 4 slaves, whatever their pole
count. Counting contacts made one 4 pole slave fill a 4 slot coil on
its own and refuse three further links that should have been allowed.
ContactUsage stays, and its per-type tally is still what the General
tab needs: how many contacts an auxiliary block must provide is a
different question from how many slots are occupied, and only the
former wants the pole multiplier. The header now says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second half of #819: where a coil declares what contacts it provides, the
General tab now reports each type as used against declared rather than as
a bare count.
NO : 3/4, NC : 1/2, inverseurs : 0/1, autres : 0/0
MasterElement::contactCapacity() sums contactCount over the element's
SlaveContactGroup list, per type, reusing the same ContactUsage tally the
used count is built on. The mapping from ElementData::SlaveState onto the
tally's own type is factored into one helper so the used count and the
declared capacity cannot classify a contact differently.
Falls back to the plain count from the previous commit when an element
declares no groups, which is every element in the standard collection
today -- nothing in the corpus declares slaveContactGroups, so this
changes no existing display.
A type used beyond what is declared reads as e.g. "1/0". That is
deliberate: it says this contact does not fit the part.
Display only. Whether a declared capacity should also feed
MasterElement::isFull() is the open question in #819 and is not touched
here.
Verified end to end against a purpose-built fixture, since no existing
element exercises this path: a coil declaring two NO groups of two, one
NC group of two and one changeover group of one parses and reports
NO=4 NC=2 SW=1 other=0 total=7, matching the declaration exactly.
tst_contactusage gains a case covering capacity summed across groups
(10 cases, all passing).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested in #819: after drawing a schematic you need to know how many
NO, NC and changeover contacts a coil ended up using, so you can pick an
auxiliary block that satisfies it. Until now the General tab reported
only a single total, and counting the contacts by type meant counting
rows on the cross reference by hand.
Three changes to that block:
- the used count now counts contacts rather than linked elements. The
label already said "contacts" while the value was
linkedElements().count(), so a slave standing for several contacts
was under-reported. It reads MasterElement::contactUsage(), the
same count isFull() uses.
- a breakdown line is added below it, printed only when the master
actually has contacts to break down.
- a declared limit of -1 means "no limit set" rather than a real
limit, so it is printed as such instead of showing "-1", which
reads as a bad value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MasterElement::isFull() decided whether a coil had room left with
connected_elements.size() >= max_slaves
which counts linked *elements*. A slave stands for as many contacts as
its "number" kind information declares, so a 4 pole contact consumed a
single contact from the coil's budget instead of four. 36 elements in
the standard collection declare a number between 2 and 4, so this is
reachable, not theoretical.
Add ContactUsage, a header-only tally holding the two rules that are
easy to get wrong:
- a slave counts once per contact it declares, not once per element
- a changeover is counted once, as sw, and never as one NO plus one
NC. CrossRefItem::NOElements() and NCElements() both return
changeovers, so a count built by adding those two lists together
reports one changeover as two contacts.
The upcoming per-type displays (the used count in the element's General
tab, and the per-type budget on the cross reference) need exactly this
count, so it lives in one place rather than being written out three
times, and isFull() now reads it too.
The header carries no graphics dependency, so the counting rules are
unit tested on their own in tests/qttest/tst_contactusage.cpp,
following the same pattern as diagramsortkeys.h.
Verified: all 9 unit tests pass, and both rules were mutation checked
(counting elements instead of contacts fails 2 tests, counting a
changeover as both NO and NC fails 3). The 23 example projects still
load and export without crash or hang, and qet-lint reports no
regressions against its baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ClickableImageLabel::mousePressEvent() calls pixmap().isNull() and
pixmap().width(). That is the Qt6 signature; in Qt5 QLabel::pixmap()
returns const QPixmap * and the code does not compile:
error: request for member 'isNull' in '...QLabel::pixmap()',
which is of pointer type 'const QPixmap*'
CMakeLists.txt defaults QT_VERSION_MAJOR to 5 when it is not specified,
so a default configuration of master has not built since 6b577ee75.
Read the pixmap once into a local, guarded the way the rest of the
codebase handles this split, which also drops four repeated pixmap()
calls in the same expression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QString::toDouble() reports a successful conversion for "nan"/"inf"/
"-inf" -- confirmed directly -- so the existing conv_ok checks in
Element::valideXml() and Terminal::valideXml() never caught a
non-finite x/y. A NaN-positioned element reaching the scene can hang
QGraphicsScene::addItem() forever: an existing conductor's itemChange()
runs a collision test (calculateTextItemPosition() ->
QGraphicsItem::collidesWithPath()) whose underlying QPathClipper spins
without terminating when fed a NaN-valued QPainterPath, since NaN
breaks the ordering comparisons the clipping algorithm's termination
depends on (#781). Short of that, a non-finite value that doesn't
happen to trigger a collision test simply gets written straight back
out on save with nothing to stop it (#782).
Element::valideXml() and Terminal::valideXml() now also check
qIsFinite() on the parsed x/y, rejecting the whole item the same way a
missing attribute already does. DynamicElementTextItem::fromXml() has
no such reject-the-item gate (void return, no caller check), so its x/y
are clamped to 0 instead -- the same fallback the attribute lookup
already uses when x/y is missing entirely.
Verified against both original findings' exact repro steps:
- #781: Habitat-Unifilaire.qet with x="nan" on one element -- hung
(SIGTERM'd by a 25s timeout) on an unfixed build, resaves cleanly
(exit 0) on this one.
- #782: grafcet.qet with y="nan" on a dynamic_elmt_text -- the value
passed straight through to the resaved file on an unfixed build;
clamped to 0 on this one. The specific field is now stable (y="0" on
two consecutive resaves) where it read "nan" both times before.
(grafcet.qet has an unrelated, already-known, unmerged fix
(PR #779) for element/terminal-order non-determinism, so a whole-file
diff across resaves still differs for reasons unconnected to this
change -- checked the specific once-NaN field in isolation instead.)
- Also checked -inf on affuteuse_250h.qet: same rejection, same result.
Full qet-dbcheck.py sweep of the unmutated example corpus (23
projects), 0 regressions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProjectConfigPage::init() is documented as "Typically, you should call this
function in your subclass constructor" -- it runs initWidgets(), initLayout(),
and (if a project is set) readValuesFromProject() and adjustReadOnly(), in
that order. ProjectMainConfigPage's constructor follows this. Until now,
ProjectAutoNumConfigPage's did not: it called initWidgets(), its own
buildConnections(), and readValuesFromProject() directly, skipping both
initLayout() and adjustReadOnly() entirely, and calling
readValuesFromProject() with no null-project guard.
In practice this was harmless today -- this subclass's initLayout() and
adjustReadOnly() overrides are both empty, and every construction site
happens to pass a real project -- but it is exactly the kind of latent
inconsistency Joshua's own refactor notes call out for this class ("remove
inconsistent virtual method usage... allow subclasses independent
implementation"). The day someone fills in adjustReadOnly() for this page
(e.g. to disable auto-numbering editing on a read-only project, which is
what the empty override's own doc comment says it is for), the constructor
path would silently never call it.
Fixed narrowly: the constructor now calls init() like its sibling does.
buildConnections() moves to the end of initWidgets(), the same relative
position it held in the constructor, so behaviour for the paths already
exercised is unchanged. This is the safe, no-redesign half of Joshua's
note; removing the init()/initWidgets()/initLayout()/readValuesFromProject()
scaffolding itself, so subclasses are free to sequence things however they
want, is a real redesign of the ConfigPage contract and needs his sign-off
on what should replace it -- not attempted here.
Verified with a GUI capture rather than by reading: opened Project
Properties on examples/industrial.qet, selected "Numérotation auto", and
confirmed the Management tab renders with its saved policy (Conductor/Element
"Both", "Apply to Entire Project") and the Conducteurs tab's combo box comes
up pre-populated with the project's saved context ("de la nouvelle
numérotation"), which on selection correctly fills the Type/Valeur/Formule
fields -- proving both readValuesFromProject() and the buildConnections()
signal wiring still work end-to-end through the new call sequence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
checkConflicts() compared key sequences across the whole registry, but
QElectroTech deliberately registers one key per editor window: Undo,
Redo, New, Open, Save and Ctrl+Shift+S each exist three times, once for
the diagram, element and titleblock editors. Those are not collisions --
they act on different windows.
The result was that 60 of the 95 shipped default bindings displayed as
conflicts, so the indicator carried no information and the page looked
broken on first open.
Conflicts are now keyed on (category, sequence). The category is the
registry's existing per-window grouping, so no new concept and no
ShortcutManager API change is needed.
Fixes#757
setTabVisible(1, ...) only allowed Simple and Master, but the tree
(updateTree()) and the actual write path (ElementScene::toXml()) both
already support elementInformations for Terminal and Thumbnail too --
three independent "is this type allowed" checks that were never
reconciled, leaving already-working support unreachable through the
UI for those two types. Slave stays excluded here, consistent with
having no elementInformations support at either of the other two
points either (separate, larger gap, not addressed here).
"false" writes, delete key instead
Unchecking a checkbox previously wrote the key with value "false"
rather than omitting it -- behaviorally identical to every consumer
(all three do a case-sensitive == "true" comparison), but left dead
entries cluttering the .elmt file, inconsistent with how other
elementInformation fields (manufacturer, designation) are only present
when actually set. Now removes the key entirely when unchecked.
numericInfoPattern()'s integer branch no longer allows an optional
trailing ".", so "12." is now Intermediate rather than Acceptable --
already-built hasAcceptableInput() guard then keeps it from being
stored, same as a lone ".". Previously it validated fine and got
saved verbatim, silently freezing in that form since nothing ever
normalized it afterward.
- ElementInfoWidget::currentInfo() now skips a field whose validator
hasn't accepted its text (e.g. a lone "." mid-typing), which
previously stored and later parsed to 0.
- New QETInformation::NumericInfoValidator rewrites "," to "." before
validating, so 80,5 on a German/French keyboard no longer silently
becomes 805. Used at both existing call sites.
- Restored the header's #1/#2/#3 doc comment (was reflowed into a
run-on paragraph by a previous edit).
excludedConductorCount() counted conductors whose terminals had no uuid,
which was the right rule when that was the reason they were dropped. It no
longer is: Terminal::stableUuid() derives an identity from the terminal's
geometry, so those conductors are in the table.
Left unchanged, the dialog would have told the user "671 conductors excluded"
on industrial.qet while listing all 671 of them -- a worse failure than the
one the count exists to prevent, because it undermines a list that is now
correct.
The count and the dialog's explanation both now describe the case that
actually remains: an endpoint attached to no element at all, which has no
identity to key on under any scheme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The summary line exists so that an empty wiring list is distinguishable
from one where every conductor was excluded, and it was reporting the wrong
number to do it. QSqlQueryModel fetches lazily, so rowCount() straight after
setQuery() returns the rows fetched so far -- 256 -- not the size of the
query. Measured with Qt's own QSQLITE driver: a 1000-row view reports 256
until the model is drained, then 1000. The test project quoted in slice 2
has 280 conductors, so this was already displaying 256 on our own data,
plausibly enough that nobody looked twice.
Drain the model before reading the count.
Also refresh the database before building the model. The dialog queries the
database rather than the diagrams, so anything not yet written through was
invisible here; with conductor text now updated on change that gap is
smaller, but a project loaded before this dialog was ever opened still
relies on the repopulate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The element and element_info tables had two independent insert paths --
addElement() for an element added to a live diagram, and
populateElementTable()/populateElementInfoTable() for a full rebuild --
which bound the same row differently. The incremental path wrote
kindInformations()["type"] into element.sub_type; the bulk path wrote
elementData().masterTypeToString(). So the table held different values
depending on whether the project had been reloaded since the element was
placed, and element_nomenclature_view exposes that column as
element_sub_type, which ElementQueryWidget filters on for the Coil,
Protection, Commutator and PLC nomenclature options.
That divergence is the same shape as the type-filter one fixed in the
previous commit, and it is the reason this stack kept finding bugs that
were invisible while editing and only appeared after a reload. Rather
than correct a second instance of it, both paths now go through
bindElementValues() and bindElementInfoValues(), following the
bindDiagramInfoValues() helper this class already had. Live and reloaded
now agree by construction instead of by coincidence.
The bulk path's values are the ones kept, because they are what every
already saved project contains: nothing a reload produces changes, and
the previous commit's 19-project BOM regression stays valid. It is the
live path that moves, onto the values a reload would have given it
anyway.
Measured, placing one element into a new project and then saving and
reopening it:
live element table: slave/ x1
reloaded element table: slave/ x1
and for the same element, what the two paths would have stored:
bulk (now shared): "" incremental (before this commit): "simple"
Re-ran the BOM regression over the same 19 projects after this change:
content identical to the pre-change baseline on all 19, and identical
line-for-line on 18, the exception being the three byte-identical
photovoltaique rows already described in the previous commit.
Note for anyone reading masterTypeToString(): the const no-argument
overload returns an empty string for anything that is not a Master, so
the "coil" fallback in the static overload is only reached for real
master elements. Non-master elements get an empty sub_type, not a
spurious "coil".
Closes the gap left open by the previous commit, at the root rather than
around it.
populateElementTable()/populateElementInfoTable() only inserted elements
matching Simple|Terminal|Master|Thumbnail. That quietly made the element
table mean "the elements a nomenclature cares about" rather than "the
elements of the project": slave elements (relay contacts) and report
elements -- ordinary conductor endpoints -- had no row at all after a
project load, so the wiring list could not name either end of a wire
that terminated on one.
Both tables are now populated with every ElementData::Type, and the type
restriction moves into element_nomenclature_view, which is where a
"what belongs in a bill of materials" decision belongs. The mask in the
view is character-for-character the one the population used to apply, so
a relay contact is still not a BOM line item.
This is safe to do in one place because every consumer of the project
database goes through a view: element_nomenclature_view (the on-diagram
nomenclature table via ElementQueryWidget, the BOM dialog, and the
--export-bom CLI) or project_summary_view (which does not reference
element at all). Nothing queries the element or element_info tables
directly -- checked across the whole tree.
Regression evidence. --export-bom runs updateDB() and then queries
element_nomenclature_view, so it is an exact harness for what the GUI
BOM shows. Captured for 19 projects (all 17 usable examples/ plus two
slave-element fixtures) before and after:
- BOM content byte-identical on all 19, compared as a multiset.
- 18 of 19 are also identical line-for-line in order.
- photovoltaique differs only in the position of three byte-identical
rows among themselves. Its query is ORDER BY label and those rows
share an empty label, so their relative order was never defined;
they are indistinguishable in the output. The on-diagram
nomenclature orders by every displayed column, so a tie there means
the rows are identical on screen too.
Effect on the wiring list, same project and same reload path: element_info
rows 0 -> 2, and the two component columns go from blank to K2 -> K1.
Cost: the database phase of loading examples/industrial.qet (150 folios,
1794 terminals) moves from 0.210 s to 0.233 s.
Slice 4 of discussion #503, on top of slice 3 (#629): the smallest
surface that makes wiring_list_view visible, plus the diagnostic the
view needs to be honest about what it is missing.
Projet > "Liste de câblage (base de données)" opens a read-only table of
wiring_list_view, headed by a line stating how many conductors are
listed and, when non-zero, how many were excluded and why.
Deliberately not another exporter. QET already ships a wiring-list CSV
export (Projet > Exporter le plan de câblage, and --export-cables) which
walks the project XML; measured on the same projects it produces a row
per conductor and resolves labels correctly when the project has them.
Adding a second, competing CSV would be worse, not better -- the
database path's value is what it unlocks (terminal plans, BOM joins),
not replacing that export.
projectDataBase::excludedConductorCount() counts, from the live scene,
the conductors deliberately absent from the conductor table because a
terminal has no uuid. Counted from the scene precisely because the
database is where those conductors are not. Verified: 671 on
examples/industrial.qet (which has 1794 terminals and no terminal uuids
at all, so its list is empty and now says so), 0 on a project whose
elements do carry terminal uuids.
KNOWN GAP, not fixed here and the reason this is opened for discussion
rather than merge: after a save/reload the component columns are blank
for slave elements. populateElementTable()/populateElementInfoTable()
only insert Simple|Terminal|Master|Thumbnail, so slave elements -- relay
contacts, i.e. a large share of real wire endpoints -- have no row in
element_info for the view to read a label from. Measured on a two-slave-
contact project after reload: element rows 0, element_info rows 0,
terminal rows 2, conductor rows 1; the wire is listed (slice 3's LEFT
JOIN keeps it) but both component names are empty, where the existing
CSV export shows K1 -> K2 for the same file.
Closing that gap means widening a filter shared with the nomenclature
and summary views, which would change what those existing, shipped
features contain. That is a maintainer decision, not one to take
unilaterally inside an additive slice.
The wiring_list_view added by this slice was only reachable through the GUI,
which meant the one thing worth proving about it -- that it still describes
the project -- could not be checked without a person clicking. This is the
same shape as the existing --export-bom, which reads
element_nomenclature_view, and it makes the view verifiable in CI.
It also makes this slice useful on its own: a from-to wiring list is a thing
people want as a CSV, and it no longer waits on the dialog in the next slice.
There is deliberately an overlap with --export-cables, which produces the same
logical list from the document XML rather than the database. Keeping both is
the point: running them and diffing them is a direct check that the cache and
the document still agree, which nothing else in the codebase can do.
Measured on the example corpus, the two also differ in what they can actually
fill in. Rows carrying any endpoint data:
--export-cables --export-wiring
industrial.qet 0 / 671 541 / 671
m_000.qet 0 / 457 362 / 457
affuteuse_250h.qet 0 / 263 197 / 263
tremie_vibrante.qet 0 / 77 61 / 77
tableau_domestique.qet 58 / 130 104 / 130
Both return a row per conductor; the XML-derived one leaves the component and
terminal columns empty on the older projects, and emits an unresolved "%id"
in its folio column. That is not an argument for removing it -- it carries
columns the view does not, and it is the independent second opinion -- but it
does mean the database path is the one with the data on the projects people
actually have.
The terminal-name columns come back empty on most projects. That is absent
source data, not a loss in transit: tableau_domestique.qet has no terminal
name on 457 of 457 terminals, and industrial.qet stores the "_" placeholder
on 1421 of 1790.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment above this view promised that it "returns exactly as many rows
as the conductor table holds", and argued carefully for the two joins that
could have broken that -- no inner join to element, and element_info LEFT
joined. Then it ended with an inner join to diagram that it never mentioned,
which can drop rows just as easily.
Feeding the real schema a conductor whose diagram_uuid has no diagram row
returned 2 view rows for 3 conductors. With the join made LEFT it returns 3,
with a null folio instead of a missing wire.
In practice this should never fire: QETProject::diagramAdded is connected to
addDiagram(), so the folio exists before anything can be drawn on it. But an
inner join turns that into an assumption the view enforces silently, and of
all the things this view can get wrong, dropping a wire from a wiring list
is the one that matters most. The comment now says which joins are inner and
why those two are safe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slice 3 of discussion #503, on top of slice 2 (#628). One row per
conductor, each endpoint resolved to its element label and terminal
name -- the `F1:4 -> M200:U1` shape from the original prototype.
The view deviates from the SQL sketched in the discussion in two ways,
both because the sketched version silently loses wires:
- **No join to the `element` table.** A terminal row already carries its
`element_uuid`, so joining `element` back just to read the same uuid
adds nothing. Worse, it filters: `populateElementTable()` only inserts
elements matching `Simple|Terminal|Master|Thumbnail`, so `Slave`
elements (relay contacts and the like -- extremely common at the end
of a wire) and report elements are simply absent from that table after
a project load, and an inner join through it drops their conductors.
- **`element_info` is LEFT joined** for the same reason. A wire whose
endpoint element has no info row still belongs in a wiring list; it
comes back with an empty label rather than vanishing. Losing a wire
from a wiring list is a worse failure than showing one with a blank
end.
Note this only bites after a save/reload. The incremental `addElement()`
path does not apply the type filter, so a slave element placed live is
present in `element`/`element_info` and an inner join looks fine -- it
is the bulk repopulate on project load that drops it. Testing only the
live-editing path would have missed this entirely.
Measured, comparing this view against an inner-join-through-element
variant built from the same tables in the same session:
| project | conductors | wiring_list_view | inner-join variant |
|---|---|---|---|
| Polonez MR'89 wiring diagram | 280 | 280 | 280 |
| two slave contacts, after save+reload | 1 | **1** | **0** |
Polonez happens to have no slave elements at conductor ends, so both
agree there and the problem is invisible. The second case is the
minimal reproduction: place two "Simple contact" elements
(`link_type="slave"`) so autoconnect wires them, save, reload -- the
sketched view returns zero rows for a project that plainly has a wire
in it.
Acceptance criterion held throughout: `wiring_list_view` row count
equals `conductor` row count, i.e. the view itself drops nothing.
Conductors already excluded upstream (legacy terminals without uuids,
see #628) stay excluded; that remains the only thing missing from the
list, and is what slice 4 should surface a count for.
checkboxes to the element editor
These elementInformation keys were previously only editable on an
already-placed instance (via ElementInfoWidget on the diagram side).
Since elementInformation values are seeded from the .elmt file's own
<elementInformations> block at placement time, a symbol author had no
proper way to set these as the *default* for every future placement --
only a workaround via the generic, unvalidated key/value tree.
Adds dedicated checkboxes to ElementPropertiesEditorWidget, mirroring
ElementInfoWidget's own labels/behavior: auto_num_locked and
potential_isolating inside the existing terminal-only group
(m_terminal_gb, shown only for ElementData::Terminal), exclude_from_bom
always visible regardless of type. Written after the generic tree loop
so they take precedence over any stale raw entry for the same key.
No new storage or file format change -- purely a missing editor UI for
an already-existing mechanism.
The "Système de contacts modifié" warning in QETProject::addElement()'s
Erase branch called QMessageBox::warning directly, bypassing
QET::QetMessageBox and so the non-interactive guard. It is reachable
during a load, which is exactly the path this PR exists to unblock, so it
could still hang a headless run.
Behaviour is unchanged interactively, and unattended it now answers with
the Yes the call site already passes as its default -- the same "continue"
the previous code took.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening an element you cannot write -- anything from the QET collection,
for instance -- disabled Copy along with everything else, so there was no
way to reuse a primitive from it. The workaround was to save the whole
element into your own collection first, just to take one shape out of it.
Two actions stood in the way, and neither of them modifies anything:
- Select All and Invert Selection were in the list disabled outright when
read only, so nothing could be selected in the first place;
- Copy was enabled only when "!m_read_only && selectedItems().count()", so
even a mouse selection left it greyed out.
Both now work on a read-only element. Copy is safe there:
ElementScene::copy() serialises the current selection to the clipboard and
touches neither the element nor the file.
Cut, Paste, Paste-in-area, Delete, Rotate, Flip, Mirror, the depth actions
and the add-primitive tools stay disabled exactly as before, so the element
is still protected -- this only stops the editor from refusing to read out
what it is already displaying.
Verified with a chmod 444 element: Select All then Copy now work, the
clipboard receives the expected <definition> with all three primitives,
Cut/Paste/Delete remain greyed out, Save stays disabled, and the file is
untouched (same permissions, same checksum).
Address feedback on the width/height/depth elementInformation fields:
- The regex accepted "." alone as a complete value ([0-9]* permits
zero digits on both sides), meaning a field could be committed with
a literal "." saved to the XML. Require at least one digit either
before or after the separator.
- The same pattern was duplicated verbatim in elementinfopartwidget.cpp
and elementpropertieseditorwidget.cpp's EditorDelegate. Factored into
QETInformation::numericInfoPattern(), a single shared definition both
call sites now use.
- Tightened the pattern to at most 2 decimal places (down from 4) to
match the precision actually meaningful for these fields.
Not changed, by design:
- Storage stays a plain string, consistent with every other numeric
elementInformation field (quantity etc.) in this codebase -- values
round-trip through XML text regardless, so a long/micron
representation wouldn't avoid the string<->number conversion, only
relocate it.
- No decimal-comma normalization needed: with the fixed pattern, only
digits and "." are ever accepted at the keystroke level, so an
alternate separator can't enter the field in the first place.
Forum report (qelectrotech.org/forum, topic 3125): "Export the names
of list of wires" doubled every conductor's name in the output - a
single conductor named "16AWG" was exported as two "16AWG" lines.
Root cause: ConductorNumExport::fillHash() incremented the name's
tally once per terminal instead of once per conductor - a separate
if-block for terminal1 and another for terminal2, each bumping the
same m_hash entry. Since an ordinary conductor has two terminals and
neither is a folio-report terminal in the common case, both blocks
fired and every real conductor was counted twice. wiresNum() then
faithfully repeats each name m_hash.value(key) times, so the doubled
count became doubled output lines.
Fixed by incrementing once per conductor, only skipping it entirely
when *both* ends are folio-report terminals (neither represents a
real connection) rather than checking each terminal independently.
Verified with a minimal two-conductor project via the --export-wires
CLI verb: pre-fix build produced 4 lines for 2 named conductors
(exact doubling), post-fix build produces the correct 2.
The selection properties dock (elementinfopartwidget.cpp) and the
element editor's information tree (elementpropertieseditorwidget.cpp's
EditorDelegate) both restrict the width/height/depth fields added for
cabinet layout support to numeric input via a validator.
QDoubleValidator follows the system/UI locale for its decimal
separator, which meant a comma was accepted as an intermediate state
and left the field impossible to leave in some contexts, even though
it was never a valid final value.
Switch both to a QRegularExpressionValidator matching
^[0-9]*\.?[0-9]{0,4}$: "." is a literal character in the pattern, not
locale-dependent, and [0-9] (rather than \d) excludes non-ASCII
digits. This guarantees a value entered this way can always be read
back with QString::toDouble() without locale handling. The check for
which keys are numeric (QETInformation::isNumericInfoKey) is shared
between both call sites; the validator setup itself stays local to
each, since QETInformation intentionally has no Qt Widgets dependency.
Also adds a placeholder ("ex. 80.5") and tooltip explaining the
expected format.
Adds three new elementInformation keys — width, height, depth (in mm)
— alongside the existing manufacturer/manufacturer_reference fields.
These describe the physical dimensions of the device a symbol
represents, set once per element definition.
Values are restricted to plain decimal numbers via a QDoubleValidator
on the information tree's item delegate (fixed-point, "." as decimal
separator via QLocale::c(), independent of the UI language), so a
later consumer can always parse them with toDouble() without
additional sanitization.
GitHub issue #663: the "Informations" tab in the element editor's
properties dialog was only made visible for Simple and Master basetypes
(setTabVisible gate in on_m_base_type_cb_currentIndexChanged), hiding it
entirely for Slave and Terminal Block elements.
This wasn't a data-model limitation: ElementData::m_informations is read
and written identically for every basetype (elementdata.cpp), and
updateTree() already special-cased Terminal as enabled and injected
PLC-specific info rows for PLC Slave elements - that logic was simply
unreachable because the tab itself was hidden for both types. Also
flipped updateTree()'s Slave case from setDisabled to setEnabled so the
tree is actually editable once visible, matching Terminal's existing
behavior.
This lets users attach manufacturer/part-number/reference metadata
directly to Terminal Block and Slave (e.g. multi-part contactor)
elements, as requested in the issue - useful when a Slave's part number
differs from its Master's (e.g. a contactor's auxiliary contact block
vs. its coil).
Not verified: interactive element-editor GUI testing wasn't performed
in this sandbox; verified via clean incremental build only.
TitleBlockTemplate::listOfVariables() -- which scans a title block
template's cells to auto-populate the "Custom" tab in Project
Properties (a feature recently added by another contributor, see
TitleBlockPropertiesWidget::addTemplateVariables()) -- only matched
the braced "%{name}" placeholder form. The bare "%name" form (also a
legitimate, fully-supported substitution syntax -- see
TitleBlockTemplate::interpreteVariables(), which already replaces both
forms) was never matched at all, not merely mishandled on edge cases:
a cell containing "%name2" alone, "%name2 " with a trailing space, or
"%name2 %name3" with two bare variables all produced zero detected
variables, exactly matching the report (manually adding the variable
in Project Properties works fine and renders correctly, since
rendering goes through interpreteVariables()'s simple string
replacement against already-known keys, not this regex).
Fix: extend the regex to also match a bare "%name" as the longest run
of identifier characters immediately after '%', via a second
alternative/capture group. This naturally stops at whitespace, so
"%name2 " and "%name2 %name3" are both now correctly detected -- no
change to the existing braced-form handling, and the existing
globalMatch() loop already correctly finds multiple matches per cell.
Verified: clean rebuild, only the intended object file recompiled and
linked successfully. Wrote a standalone test of the regex/extraction
logic covering exactly the reported repro cases -- "%name2", "%name2 "
(trailing space), "%name2 %name3" (two bare variables), "%{name2}"
(braced form, unaffected), a braced+bare mix, plain text with no
variables, and two built-in-style names -- all extracted correctly
with no regressions to the previously-working braced form.
Not verified: the actual Project Properties "Custom" tab UI
auto-populating live, since exercising the full Xvfb GUI flow (title
block template editor > add a bare-form custom variable to a cell >
save > open Project Properties > select that template > confirm the
Custom tab lists it) was out of scope for the time available given the
extraction logic itself was already precisely verified in isolation.
DynamicElementTextItem's slave cross-reference sub-item
(m_slave_Xref_item, the small "_(1-D3)_"-style text next to a slave
element pointing back to its master) hardcoded Qt::black in three
places: on creation, on hover-leave, and when restoring color after
text editing. The PARENT text item's color is a real, user-configurable
property (color()/setColor(), persisted in the diagram XML, exposed in
the text editor's color picker) -- but the slave-Xref sub-item never
used it, so a user applying a dark theme/stylesheet had no way to make
this specific text visible even by explicitly setting a text color,
unlike every other text item in the diagram.
Fix: use color() (the parent DynamicElementTextItem's own configured
color, inherited from DiagramTextItem) instead of the Qt::black
constant in all three places. This doesn't change the default
appearance (color() defaults to black, same as before) but makes the
slave-Xref text finally respect whatever color the user sets on the
parent text field, giving dark-theme users the same escape hatch
already available for all other diagram text.
Verified: clean rebuild, only the intended object file recompiled and
linked successfully.
Not verified: a live visual confirmation of the slave-Xref text
picking up a non-default color, since reproducing this requires
constructing a master/slave-linked element pair with composite text
containing %{label} in an actual multi-folio project, which was out of
scope for the time available. Confidence rests on this being a direct,
mechanical substitution of an existing, already-used accessor
(color()) for a hardcoded constant, applied identically to the exact
three call sites that previously hardcoded Qt::black for this item,
with no other logic changed.
QETDiagramEditor::openBackupFiles() deleted the just-constructed
QETProject when it failed to reach ProjectState::Ok, but had no
continue/else after the delete - so addProject(project) ran
unconditionally on the now-dangling pointer, and addProject()
immediately dereferences it (new ProjectView(project), etc.).
This matches the report exactly: clicking Cancel on the restore-files
dialog (which just deletes the stale markers directly, never calling
openBackupFiles()) works fine, while clicking OK crashes whenever any
listed backup fails to open cleanly. Because the crash happens mid-
loop, cleanup for that file (and any later ones in the same batch)
never completes, which also explains the reporter's second complaint
that the restore list kept growing across sessions.
Fix: add the missing `continue` so a failed project is skipped
instead of being passed use-after-free to addProject().
Verified: clean rebuild, only the intended object file recompiled
and linked successfully. I attempted a live repro by crafting a
malformed stale-file marker to force ProjectState != Ok and clicking
OK under Xvfb, but this local build links against real KDE Frameworks
(BUILD_WITH_KF5=ON, confirmed via CMakeCache.txt) rather than the
in-tree nokde/kautosavefile.cpp reimplementation I initially targeted,
which uses a different marker directory/naming scheme
(~/.local/share/stalefiles/<app>/ via real KF5::KAutoSaveFile) that
I wasn't able to reverse-engineer well enough in the time available
to produce a matching malformed marker. Confidence in the fix instead
rests on the code being an unambiguous, textbook use-after-free (this
exact object is deleted on the line immediately above the missing
continue) with a single-line, side-effect-free fix.
Removed "isVisible" check on max_slave_checkbox before saving because isVisible is maybe not true, when the ok button is pressed
Then the value -1 is written and so on not saved to the elements xml
updateInformations() runs on every selection change and on every undo
stack index change. It unconditionally called clearToolsDock(), which
removes and hides the current editor widget, and then re-inserted the
same widget into the stack. When the editor for the new selection is
the one already shown, that means removing, hiding, reparenting and
re-adding a widget only to end up in the same state.
Look up the editor first and only clear and re-insert the tools dock
when a different editor is needed. setPart()/setParts() still updates
the contents in every case, so the visible result is unchanged.
`qelectrotech --resave examples/schema_indus.qet out.qet` never returns.
It is not slow -- ten minutes of wall clock consumed 0.16s of CPU, so it
is blocked, not working. The GUI opens the same project without
complaint, so the file is fine and the fault is in the headless path.
A backtrace of the stuck process:
main
CLIExport::run
QETProject::QETProject(QString const&, QObject*)
QETProject::openFile(QFile*)
QETProject::readProjectXml(QDomDocument&)
QET::QetMessageBox::warning(...)
QDialog::exec() <- waits forever
That project records version="0.3", so loading it raises the "partially
compatible with your version" warning. Interactively somebody presses
Open; with no display nobody can, and exec() spins its event loop
indefinitely. Any modal box reachable while loading does this -- the
version warning is just the one an example file happens to trigger.
Fixed at the wrapper all 52 call sites already go through rather than at
the one warning, so the whole class is closed: QetMessageBox gains a
non-interactive mode which writes the message to stderr and returns an
answer instead of constructing a dialog. main.cpp turns it on in the
CLI branch, beside the existing setBackupEnabled(false).
The answer is the caller's defaultButton when it gave one, otherwise the
first "carry on" button offered (Ok, Open, Yes, Save...), otherwise the
first button set. Both warnings in readProjectXml offer Open|Cancel and
abort on Cancel, so they resolve to Open and the project loads, which is
what a batch invocation wants. The text still reaches the user on
stderr, where previously it was lost inside an invisible dialog.
GUI behaviour is unchanged: the flag defaults to false and is set in
exactly one place, the command-line branch of main().
Verified: schema_indus.qet goes from hanging to resaving in 0.3s; all 23
example projects now complete a double-resave with element, conductor,
terminal and uuid sets intact; unit tests pass.
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
529 changed files with 82741 additions and 34874 deletions
| CMake ≥ 3.5 | required | CMake 4.3+ is also fine, see note below |
| 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 |
| 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) |
QElectroTech és una aplicació Qt5 per crear esquemes elèctrics.
QElectroTech és una aplicació Qt6 per crear esquemes elèctrics.
QET utilitza el format XML per als seus elements i esquemes i inclou un editor d'esquemes, un editor d'elements i un editor de caixetins.
[en]
QElectroTech is a Qt5 application to design electric diagrams.
QElectroTech is a Qt6 application to design electric diagrams.
It uses XML files for elements and diagrams, and includes both a diagram editor, a element editor, and an titleblock editor.
[fr]
QElectroTech est une application Qt5 pour réaliser des schémas électriques.
QElectroTech est une application Qt6 pour réaliser des schémas électriques.
QET utilise le format XML pour ses éléments et ses schémas et inclut un éditeur de schémas, un éditeur d'élément, ainsi qu'un editeur de cartouche.
[de]
QElectroTech ist eine Qt5 Software, um Schaltpläne zu erstellen.
QElectroTech ist eine Qt6 Software, um Schaltpläne zu erstellen.
QET benutzt das XML Format für seine Bauteile und seine Projekte, und beinhaltet einen Schaltplaneditor, einen Bauteileditor sowie einen Schriftfeldeditor.
[ru]
QElectroTech - приложение написанное на Qt5 и предназначенное для разработки электрических схем.
QElectroTech - приложение написанное на Qt6 и предназначенное для разработки электрических схем.
Оно использует XML-файлы для элементов и схем, и включает, как редактор схем, так и редактор элементов.
[pt]
QElectroTech é uma aplicação baseada em Qt5 para desenhar esquemas eléctricos.
QElectroTech é uma aplicação baseada em Qt6 para desenhar esquemas eléctricos.
QET utiliza ficheiros XML para os elementos e para os esquemas e inclui um editor de esquemas e um editor de elementos.
[es]
QElectroTech es una aplicación Qt5 para diseñar esquemas eléctricos.
QElectroTech es una aplicación Qt6 para diseñar esquemas eléctricos.
Utiliza archivos XML para los elementos y esquemas, e incluye un editor de esquemas y un editor de elementos.
[cs]
QElectroTech je aplikací Qt5 určenou pro návrh nákresů elektrických obvodů.
QElectroTech je aplikací Qt6 určenou pro návrh nákresů elektrických obvodů.
Pro prvky a nákresy používá soubory XML, a zahrnuje v sobě jak editor nákresů, tak editor prvků.
[pl]
QElectroTech to aplikacja napisana w Qt5, przeznaczona do tworzenia schematów elektrycznych.
QElectroTech to aplikacja napisana w Qt6, przeznaczona do tworzenia schematów elektrycznych.
Wykorzystuje XML do zapisywania plików elementów i projektów. Posiada edytor schematów i elementów.
[it]
QElectroTech è una applicazione fatta in Qt5 per disegnare schemi elettrici.
QElectroTech è una applicazione fatta in Qt6 per disegnare schemi elettrici.
QET usa il formato XML per i suoi elementi e schemi, includendo anche un editor per gli stessi.
[el]
Το QElectroTech είναι μια εφαρμογή Qt5 για σχεδίαση ηλεκτρικών διαγραμμάτων.
Το QElectroTech είναι μια εφαρμογή Qt6 για σχεδίαση ηλεκτρικών διαγραμμάτων.
Χρησιμοποιεί αρχεία XML για στοιχεία και διαγράμματα, και περιλαμβάνει επεξεργαστή διαγραμμάτων καθώς και επεξεργαστή στοιχείων.
[nl]
QElectroTech is een Qt5 applicatie om elektrische schema's te ontwerpen.
QElectroTech is een Qt6 applicatie om elektrische schema's te ontwerpen.
Het maakt gebruik van XML-bestanden voor elementen en diagrammen, en omvat zowel een diagram bewerker, een element bewerker, en een bloksjabloon bewerker.
[be]
QElectroTech is een Qt5 toepassing voor het maken en beheren van elektrische schema's.
QElectroTech is een Qt6 toepassing voor het maken en beheren van elektrische schema's.
QET gebruikt XML voor de elementen en schema's en omvat een schematische editor, itemeditor, en een titel sjabloon editor.
[da]
QElectroTech er et Qt5 program til at redigere elektriske diagrammer.
QElectroTech er et Qt6 program til at redigere elektriske diagrammer.
Det bruger XML filer for symboler og diagrammer og inkluderer diagram, symbol og titelblok redigering.
[ja]
QElectroTech は電気回路図を作成する Qt5 アプリケーションです。
QElectroTech は電気回路図を作成する Qt6 アプリケーションです。
QET は要素と回路図に XML 形式を利用し、回路図エディタ、要素エディタ、表題欄エディタを含みます。
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)
ico/breeze-icons/* by Nuri from the QElectroTech team (License CC BY-ND 3.0)
ico/diagram.png by Nuri from the QElectroTech team (License CC BY-ND 3.0)
<pathstyle="fill:currentColor;fill-opacity:1;stroke:none"d="M 3 5 h 1 v 1 h -1 Z M 3 6 h 1 v 1 h -1 Z M 3 7 h 1 v 1 h -1 Z M 3 8 h 1 v 1 h -1 Z M 3 9 h 1 v 1 h -1 Z M 3 10 h 1 v 1 h -1 Z M 3 11 h 1 v 1 h -1 Z M 3 12 h 1 v 1 h -1 Z M 3 13 h 1 v 1 h -1 Z M 3 14 h 1 v 1 h -1 Z M 3 15 h 1 v 1 h -1 Z M 3 16 h 1 v 1 h -1 Z M 4 5 h 1 v 1 h -1 Z M 4 13 h 1 v 1 h -1 Z M 4 16 h 1 v 1 h -1 Z M 5 5 h 1 v 1 h -1 Z M 5 13 h 1 v 1 h -1 Z M 5 16 h 1 v 1 h -1 Z M 6 5 h 1 v 1 h -1 Z M 6 13 h 1 v 1 h -1 Z M 6 16 h 1 v 1 h -1 Z M 7 5 h 1 v 1 h -1 Z M 7 13 h 1 v 1 h -1 Z M 7 16 h 1 v 1 h -1 Z M 8 5 h 1 v 1 h -1 Z M 8 13 h 1 v 1 h -1 Z M 8 16 h 1 v 1 h -1 Z M 9 5 h 1 v 1 h -1 Z M 9 13 h 1 v 1 h -1 Z M 9 16 h 1 v 1 h -1 Z M 10 5 h 1 v 1 h -1 Z M 10 13 h 1 v 1 h -1 Z M 10 16 h 1 v 1 h -1 Z M 11 5 h 1 v 1 h -1 Z M 11 13 h 1 v 1 h -1 Z M 11 14 h 1 v 1 h -1 Z M 11 15 h 1 v 1 h -1 Z M 11 16 h 1 v 1 h -1 Z M 12 5 h 1 v 1 h -1 Z M 12 13 h 1 v 1 h -1 Z M 12 16 h 1 v 1 h -1 Z M 13 5 h 1 v 1 h -1 Z M 13 13 h 1 v 1 h -1 Z M 13 16 h 1 v 1 h -1 Z M 14 5 h 1 v 1 h -1 Z M 14 13 h 1 v 1 h -1 Z M 14 16 h 1 v 1 h -1 Z M 15 5 h 1 v 1 h -1 Z M 15 13 h 1 v 1 h -1 Z M 15 16 h 1 v 1 h -1 Z M 16 5 h 1 v 1 h -1 Z M 16 13 h 1 v 1 h -1 Z M 16 16 h 1 v 1 h -1 Z M 17 5 h 1 v 1 h -1 Z M 17 13 h 1 v 1 h -1 Z M 17 16 h 1 v 1 h -1 Z M 18 5 h 1 v 1 h -1 Z M 18 6 h 1 v 1 h -1 Z M 18 7 h 1 v 1 h -1 Z M 18 8 h 1 v 1 h -1 Z M 18 9 h 1 v 1 h -1 Z M 18 10 h 1 v 1 h -1 Z M 18 11 h 1 v 1 h -1 Z M 18 12 h 1 v 1 h -1 Z M 18 13 h 1 v 1 h -1 Z M 18 14 h 1 v 1 h -1 Z M 18 15 h 1 v 1 h -1 Z M 18 16 h 1 v 1 h -1 Z"class="ColorScheme-Text"/>
<pathstyle="fill:currentColor;fill-opacity:1;stroke:none"d="M 3 5 h 1 v 1 h -1 Z M 3 6 h 1 v 1 h -1 Z M 3 7 h 1 v 1 h -1 Z M 3 8 h 1 v 1 h -1 Z M 3 9 h 1 v 1 h -1 Z M 3 10 h 1 v 1 h -1 Z M 3 11 h 1 v 1 h -1 Z M 3 12 h 1 v 1 h -1 Z M 3 13 h 1 v 1 h -1 Z M 3 14 h 1 v 1 h -1 Z M 3 15 h 1 v 1 h -1 Z M 3 16 h 1 v 1 h -1 Z M 4 5 h 1 v 1 h -1 Z M 4 13 h 1 v 1 h -1 Z M 4 16 h 1 v 1 h -1 Z M 5 5 h 1 v 1 h -1 Z M 5 13 h 1 v 1 h -1 Z M 5 16 h 1 v 1 h -1 Z M 6 5 h 1 v 1 h -1 Z M 6 13 h 1 v 1 h -1 Z M 6 16 h 1 v 1 h -1 Z M 7 5 h 1 v 1 h -1 Z M 7 13 h 1 v 1 h -1 Z M 7 16 h 1 v 1 h -1 Z M 8 5 h 1 v 1 h -1 Z M 8 13 h 1 v 1 h -1 Z M 8 16 h 1 v 1 h -1 Z M 9 5 h 1 v 1 h -1 Z M 9 13 h 1 v 1 h -1 Z M 9 16 h 1 v 1 h -1 Z M 10 5 h 1 v 1 h -1 Z M 10 13 h 1 v 1 h -1 Z M 10 16 h 1 v 1 h -1 Z M 11 5 h 1 v 1 h -1 Z M 11 13 h 1 v 1 h -1 Z M 11 14 h 1 v 1 h -1 Z M 11 15 h 1 v 1 h -1 Z M 11 16 h 1 v 1 h -1 Z M 12 5 h 1 v 1 h -1 Z M 12 13 h 1 v 1 h -1 Z M 12 16 h 1 v 1 h -1 Z M 13 5 h 1 v 1 h -1 Z M 14 5 h 1 v 1 h -1 Z M 14 17 h 1 v 1 h -1 Z M 15 5 h 1 v 1 h -1 Z M 15 17 h 1 v 1 h -1 Z M 16 5 h 1 v 1 h -1 Z M 16 17 h 1 v 1 h -1 Z M 17 5 h 1 v 1 h -1 Z M 17 17 h 1 v 1 h -1 Z M 18 5 h 1 v 1 h -1 Z M 18 6 h 1 v 1 h -1 Z M 18 7 h 1 v 1 h -1 Z M 18 8 h 1 v 1 h -1 Z M 18 9 h 1 v 1 h -1 Z M 18 10 h 1 v 1 h -1 Z M 18 11 h 1 v 1 h -1 Z M 18 12 h 1 v 1 h -1 Z M 18 17 h 1 v 1 h -1 Z M 19 17 h 1 v 1 h -1 Z"class="ColorScheme-Text"/>
<pathstyle="fill:currentColor;fill-opacity:1;stroke:none"d="M 3 5 h 1 v 1 h -1 Z M 3 6 h 1 v 1 h -1 Z M 3 7 h 1 v 1 h -1 Z M 3 8 h 1 v 1 h -1 Z M 3 9 h 1 v 1 h -1 Z M 3 10 h 1 v 1 h -1 Z M 3 11 h 1 v 1 h -1 Z M 3 12 h 1 v 1 h -1 Z M 3 13 h 1 v 1 h -1 Z M 3 14 h 1 v 1 h -1 Z M 3 15 h 1 v 1 h -1 Z M 3 16 h 1 v 1 h -1 Z M 4 5 h 1 v 1 h -1 Z M 4 13 h 1 v 1 h -1 Z M 4 16 h 1 v 1 h -1 Z M 5 5 h 1 v 1 h -1 Z M 5 13 h 1 v 1 h -1 Z M 5 16 h 1 v 1 h -1 Z M 6 5 h 1 v 1 h -1 Z M 6 13 h 1 v 1 h -1 Z M 6 16 h 1 v 1 h -1 Z M 7 5 h 1 v 1 h -1 Z M 7 13 h 1 v 1 h -1 Z M 7 16 h 1 v 1 h -1 Z M 8 5 h 1 v 1 h -1 Z M 8 13 h 1 v 1 h -1 Z M 8 16 h 1 v 1 h -1 Z M 9 5 h 1 v 1 h -1 Z M 9 13 h 1 v 1 h -1 Z M 9 16 h 1 v 1 h -1 Z M 10 5 h 1 v 1 h -1 Z M 10 13 h 1 v 1 h -1 Z M 10 16 h 1 v 1 h -1 Z M 11 5 h 1 v 1 h -1 Z M 11 13 h 1 v 1 h -1 Z M 11 14 h 1 v 1 h -1 Z M 11 15 h 1 v 1 h -1 Z M 11 16 h 1 v 1 h -1 Z M 12 5 h 1 v 1 h -1 Z M 12 13 h 1 v 1 h -1 Z M 12 16 h 1 v 1 h -1 Z M 13 5 h 1 v 1 h -1 Z M 14 5 h 1 v 1 h -1 Z M 14 17 h 1 v 1 h -1 Z M 15 5 h 1 v 1 h -1 Z M 15 17 h 1 v 1 h -1 Z M 16 5 h 1 v 1 h -1 Z M 16 17 h 1 v 1 h -1 Z M 17 5 h 1 v 1 h -1 Z M 17 14 h 1 v 1 h -1 Z M 17 15 h 1 v 1 h -1 Z M 17 16 h 1 v 1 h -1 Z M 17 17 h 1 v 1 h -1 Z M 17 18 h 1 v 1 h -1 Z M 17 19 h 1 v 1 h -1 Z M 18 5 h 1 v 1 h -1 Z M 18 6 h 1 v 1 h -1 Z M 18 7 h 1 v 1 h -1 Z M 18 8 h 1 v 1 h -1 Z M 18 9 h 1 v 1 h -1 Z M 18 10 h 1 v 1 h -1 Z M 18 11 h 1 v 1 h -1 Z M 18 12 h 1 v 1 h -1 Z M 18 17 h 1 v 1 h -1 Z M 19 17 h 1 v 1 h -1 Z"class="ColorScheme-Text"/>
<pathstyle="fill:currentColor;fill-opacity:1;stroke:none"d="M 3 5 h 1 v 1 h -1 Z M 3 6 h 1 v 1 h -1 Z M 3 7 h 1 v 1 h -1 Z M 3 8 h 1 v 1 h -1 Z M 3 9 h 1 v 1 h -1 Z M 3 10 h 1 v 1 h -1 Z M 3 11 h 1 v 1 h -1 Z M 3 12 h 1 v 1 h -1 Z M 3 13 h 1 v 1 h -1 Z M 3 14 h 1 v 1 h -1 Z M 3 15 h 1 v 1 h -1 Z M 3 16 h 1 v 1 h -1 Z M 4 5 h 1 v 1 h -1 Z M 4 13 h 1 v 1 h -1 Z M 4 16 h 1 v 1 h -1 Z M 5 5 h 1 v 1 h -1 Z M 5 13 h 1 v 1 h -1 Z M 5 16 h 1 v 1 h -1 Z M 6 5 h 1 v 1 h -1 Z M 6 8 h 1 v 1 h -1 Z M 6 10 h 1 v 1 h -1 Z M 6 13 h 1 v 1 h -1 Z M 6 16 h 1 v 1 h -1 Z M 7 5 h 1 v 1 h -1 Z M 7 8 h 1 v 1 h -1 Z M 7 10 h 1 v 1 h -1 Z M 7 13 h 1 v 1 h -1 Z M 7 16 h 1 v 1 h -1 Z M 8 5 h 1 v 1 h -1 Z M 8 8 h 1 v 1 h -1 Z M 8 10 h 1 v 1 h -1 Z M 8 13 h 1 v 1 h -1 Z M 8 16 h 1 v 1 h -1 Z M 9 5 h 1 v 1 h -1 Z M 9 8 h 1 v 1 h -1 Z M 9 10 h 1 v 1 h -1 Z M 9 13 h 1 v 1 h -1 Z M 9 16 h 1 v 1 h -1 Z M 10 5 h 1 v 1 h -1 Z M 10 8 h 1 v 1 h -1 Z M 10 10 h 1 v 1 h -1 Z M 10 13 h 1 v 1 h -1 Z M 10 16 h 1 v 1 h -1 Z M 11 5 h 1 v 1 h -1 Z M 11 8 h 1 v 1 h -1 Z M 11 10 h 1 v 1 h -1 Z M 11 13 h 1 v 1 h -1 Z M 11 14 h 1 v 1 h -1 Z M 11 15 h 1 v 1 h -1 Z M 11 16 h 1 v 1 h -1 Z M 12 5 h 1 v 1 h -1 Z M 12 8 h 1 v 1 h -1 Z M 12 10 h 1 v 1 h -1 Z M 12 13 h 1 v 1 h -1 Z M 12 16 h 1 v 1 h -1 Z M 13 5 h 1 v 1 h -1 Z M 13 8 h 1 v 1 h -1 Z M 13 10 h 1 v 1 h -1 Z M 13 13 h 1 v 1 h -1 Z M 13 16 h 1 v 1 h -1 Z M 14 5 h 1 v 1 h -1 Z M 14 8 h 1 v 1 h -1 Z M 14 10 h 1 v 1 h -1 Z M 14 13 h 1 v 1 h -1 Z M 14 16 h 1 v 1 h -1 Z M 15 5 h 1 v 1 h -1 Z M 15 8 h 1 v 1 h -1 Z M 15 10 h 1 v 1 h -1 Z M 15 13 h 1 v 1 h -1 Z M 15 16 h 1 v 1 h -1 Z M 16 5 h 1 v 1 h -1 Z M 16 13 h 1 v 1 h -1 Z M 16 16 h 1 v 1 h -1 Z M 17 5 h 1 v 1 h -1 Z M 17 13 h 1 v 1 h -1 Z M 17 16 h 1 v 1 h -1 Z M 18 5 h 1 v 1 h -1 Z M 18 6 h 1 v 1 h -1 Z M 18 7 h 1 v 1 h -1 Z M 18 8 h 1 v 1 h -1 Z M 18 9 h 1 v 1 h -1 Z M 18 10 h 1 v 1 h -1 Z M 18 11 h 1 v 1 h -1 Z M 18 12 h 1 v 1 h -1 Z M 18 13 h 1 v 1 h -1 Z M 18 14 h 1 v 1 h -1 Z M 18 15 h 1 v 1 h -1 Z M 18 16 h 1 v 1 h -1 Z"class="ColorScheme-Text"/>
<pathstyle="fill:currentColor;fill-opacity:1;stroke:none"d="M 3 5 h 1 v 1 h -1 Z M 3 6 h 1 v 1 h -1 Z M 3 7 h 1 v 1 h -1 Z M 3 8 h 1 v 1 h -1 Z M 3 9 h 1 v 1 h -1 Z M 3 10 h 1 v 1 h -1 Z M 3 11 h 1 v 1 h -1 Z M 3 12 h 1 v 1 h -1 Z M 3 13 h 1 v 1 h -1 Z M 3 14 h 1 v 1 h -1 Z M 3 15 h 1 v 1 h -1 Z M 3 16 h 1 v 1 h -1 Z M 4 5 h 1 v 1 h -1 Z M 4 13 h 1 v 1 h -1 Z M 4 14 h 1 v 1 h -1 Z M 4 15 h 1 v 1 h -1 Z M 4 16 h 1 v 1 h -1 Z M 5 5 h 1 v 1 h -1 Z M 5 13 h 1 v 1 h -1 Z M 5 14 h 1 v 1 h -1 Z M 5 15 h 1 v 1 h -1 Z M 5 16 h 1 v 1 h -1 Z M 6 5 h 1 v 1 h -1 Z M 6 13 h 1 v 1 h -1 Z M 6 14 h 1 v 1 h -1 Z M 6 15 h 1 v 1 h -1 Z M 6 16 h 1 v 1 h -1 Z M 7 5 h 1 v 1 h -1 Z M 7 13 h 1 v 1 h -1 Z M 7 14 h 1 v 1 h -1 Z M 7 15 h 1 v 1 h -1 Z M 7 16 h 1 v 1 h -1 Z M 8 5 h 1 v 1 h -1 Z M 8 13 h 1 v 1 h -1 Z M 8 14 h 1 v 1 h -1 Z M 8 15 h 1 v 1 h -1 Z M 8 16 h 1 v 1 h -1 Z M 9 5 h 1 v 1 h -1 Z M 9 13 h 1 v 1 h -1 Z M 9 14 h 1 v 1 h -1 Z M 9 15 h 1 v 1 h -1 Z M 9 16 h 1 v 1 h -1 Z M 10 5 h 1 v 1 h -1 Z M 10 13 h 1 v 1 h -1 Z M 10 14 h 1 v 1 h -1 Z M 10 15 h 1 v 1 h -1 Z M 10 16 h 1 v 1 h -1 Z M 11 5 h 1 v 1 h -1 Z M 11 13 h 1 v 1 h -1 Z M 11 14 h 1 v 1 h -1 Z M 11 15 h 1 v 1 h -1 Z M 11 16 h 1 v 1 h -1 Z M 12 5 h 1 v 1 h -1 Z M 12 13 h 1 v 1 h -1 Z M 12 14 h 1 v 1 h -1 Z M 12 15 h 1 v 1 h -1 Z M 12 16 h 1 v 1 h -1 Z M 13 5 h 1 v 1 h -1 Z M 13 13 h 1 v 1 h -1 Z M 13 14 h 1 v 1 h -1 Z M 13 15 h 1 v 1 h -1 Z M 13 16 h 1 v 1 h -1 Z M 14 5 h 1 v 1 h -1 Z M 14 13 h 1 v 1 h -1 Z M 14 14 h 1 v 1 h -1 Z M 14 15 h 1 v 1 h -1 Z M 14 16 h 1 v 1 h -1 Z M 15 5 h 1 v 1 h -1 Z M 15 13 h 1 v 1 h -1 Z M 15 14 h 1 v 1 h -1 Z M 15 15 h 1 v 1 h -1 Z M 15 16 h 1 v 1 h -1 Z M 16 5 h 1 v 1 h -1 Z M 16 13 h 1 v 1 h -1 Z M 16 14 h 1 v 1 h -1 Z M 16 15 h 1 v 1 h -1 Z M 16 16 h 1 v 1 h -1 Z M 17 5 h 1 v 1 h -1 Z M 17 13 h 1 v 1 h -1 Z M 17 14 h 1 v 1 h -1 Z M 17 15 h 1 v 1 h -1 Z M 17 16 h 1 v 1 h -1 Z M 18 5 h 1 v 1 h -1 Z M 18 6 h 1 v 1 h -1 Z M 18 7 h 1 v 1 h -1 Z M 18 8 h 1 v 1 h -1 Z M 18 9 h 1 v 1 h -1 Z M 18 10 h 1 v 1 h -1 Z M 18 11 h 1 v 1 h -1 Z M 18 12 h 1 v 1 h -1 Z M 18 13 h 1 v 1 h -1 Z M 18 14 h 1 v 1 h -1 Z M 18 15 h 1 v 1 h -1 Z M 18 16 h 1 v 1 h -1 Z"class="ColorScheme-Text"/>
<pathstyle="fill:currentColor;fill-opacity:1;stroke:none"d="M 3 3 h 16 v 1 h -16 Z M 3 4 h 1 v 15 h -1 Z M 4 18 h 10 v 1 h -10 Z M 18 4 h 1 v 8 h -1 Z M 16 14 h 1 v 5 h -1 Z M 14 16 h 5 v 1 h -5 Z"class="ColorScheme-Text"/>
<pathstyle="fill:currentColor;fill-opacity:1;stroke:none"d="M 5 7 h 1 v 1 h -1 Z M 6 7 h 1 v 1 h -1 Z M 7 7 h 1 v 1 h -1 Z M 5 8 h 1 v 1 h -1 Z M 7 8 h 1 v 1 h -1 Z M 5 9 h 1 v 1 h -1 Z M 6 9 h 1 v 1 h -1 Z M 7 9 h 1 v 1 h -1 Z M 5 10 h 1 v 1 h -1 Z M 5 11 h 1 v 1 h -1 Z M 9 7 h 1 v 1 h -1 Z M 10 7 h 1 v 1 h -1 Z M 9 8 h 1 v 1 h -1 Z M 11 8 h 1 v 1 h -1 Z M 9 9 h 1 v 1 h -1 Z M 11 9 h 1 v 1 h -1 Z M 9 10 h 1 v 1 h -1 Z M 11 10 h 1 v 1 h -1 Z M 9 11 h 1 v 1 h -1 Z M 10 11 h 1 v 1 h -1 Z M 13 7 h 1 v 1 h -1 Z M 14 7 h 1 v 1 h -1 Z M 15 7 h 1 v 1 h -1 Z M 13 8 h 1 v 1 h -1 Z M 13 9 h 1 v 1 h -1 Z M 14 9 h 1 v 1 h -1 Z M 13 10 h 1 v 1 h -1 Z M 13 11 h 1 v 1 h -1 Z"class="ColorScheme-Text"/>
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.