Commit Graph

9338 Commits

Author SHA1 Message Date
Laurent Trinques 451f8c3296 Merge pull request #892 from jp2images/fix-terminal-destructor-snapshot
Delete a terminal's conductors from a snapshot of its conductor list
2026-09-16 11:49:40 +02:00
Laurent Trinques 43a1f6a1a9 Merge pull request #856 from Kellermorph/fix-copy-page
Fix text position shift when duplicating diagram pages
2026-09-16 11:44:18 +02:00
Jeff Patterson b208a4f131 Show the text color as a swatch and keep the terminal plan preview on white
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.
2026-09-16 04:39:31 -05:00
Jeff Patterson 1c8e7ed052 Give Fusion a palette it can draw with on macOS
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.
2026-09-16 04:39:31 -05:00
Kellermorph e6ffc8b622 Address all review feedback 2026-09-16 11:37:27 +02:00
Laurent Trinques 9004db1d99 Reload element drawings: keep old drawing on failure, skip elements whose geometry changed (#802)
- 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.
2026-09-16 21:35:17 +12:00
Jeff Patterson edf483d88f Delete a terminal's conductors from a snapshot of its conductor list
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.
2026-09-16 04:27:27 -05:00
ispyisail 7a85e2592c Fix stale geometry in LineEditor on multi-select
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>
2026-09-16 21:27:22 +12:00
ispyisail 7183ed3d99 Merge remote-tracking branch 'upstream/master' into fix/675-line-editor-stale-refresh 2026-09-16 21:21:08 +12:00
ispyisail 0920e82188 Add geometry editing, undo integration, and navigation to scripting
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>
2026-09-16 20:20:58 +12:00
ispyisail eba258f6cd Add JavaScript scripting: --run and "Run Script..." (bugtracker #162)
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>
2026-09-16 19:44:14 +12:00
ispyisail 2eed3acd3d Merge pull request #723 from IBSYSLevi/feature/cabinet-layout
Added width/height/depth properties to elements
2026-09-16 18:51:34 +12:00
ispyisail abd5e8978a Fix bugtracker #734: stop routing a bridge conductors don't need
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>
2026-09-16 17:17:03 +12:00
ispyisail 43d27a9563 Add "Reload element drawings" to refresh placed elements (#802)
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>
2026-09-16 16:24:44 +12:00
Laurent Trinques d3424db23d Merge pull request #883 from bhangart/persist-diagram-uuid
Persist the folio uuid, derived deterministically for legacy folios
2026-09-16 05:58:19 +02:00
Laurent Trinques 0d722b6033 Merge pull request #888 from ispyisail/fix/879-remember-conductor-color
Remember the F2 conductor color for the rest of the session
2026-09-16 05:52:29 +02:00
Laurent Trinques 96fac96b89 Merge pull request #884 from bhangart/persist-project-uuid
Persist the project uuid, derived from the file content for legacy files
2026-09-16 05:47:12 +02:00
ispyisail 1ec4f56a50 Remember the F2 conductor color for the rest of the session (#879)
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>
2026-09-16 15:40:41 +12:00
Laurent Trinques 839324f231 Merge pull request #887 from ispyisail/fix/885-summary-custom-sql-mode
Fix bugtracker #885: preserve custom summary-table SQL on accept
2026-09-16 05:37:43 +02:00
ispyisail a756bc0722 Fix bugtracker #885: preserve custom summary-table SQL on accept
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.
2026-09-16 13:09:48 +12:00
Beat Hangartner 2269a4641b Persist the project uuid, derived from the file content for legacy files
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
2026-09-15 23:34:24 +00:00
Beat Hangartner 7cd04f2a14 Persist the folio uuid, derived deterministically for legacy folios
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
2026-09-15 22:09:10 +00:00
Kellermorph c2f405c32d Add Hide full masters checkbox to slave linking widget 2026-09-15 21:48:47 +02:00
Laurent Trinques 265aa44320 Merge pull request #858 from Kellermorph/checkbox-plc-connection
Add Hide linked elements checkbox to PLC link widget
2026-09-15 18:44:24 +02:00
Laurent Trinques 395c6f6602 Merge pull request #876 from ispyisail/fix/keyboard-context-menu
Give the keyboard the folio's context menu, not a generic one
2026-09-15 16:15:04 +02:00
Laurent Trinques fd38f55724 Merge pull request #874 from ispyisail/feature/diagram-selection-shortcuts-v2
Add Tab selection cycling and select-all conductors/text fields
2026-09-15 10:20:28 +02:00
Laurent Trinques 85ed1b8a2a Merge pull request #878 from ispyisail/feature/paste-follows-cursor
Paste under the cursor, and let it be positioned before it lands
2026-09-15 10:16:06 +02:00
Laurent Trinques 607eb4b1ea Merge pull request #871 from ispyisail/test/ipc-open-forwarding-regression
Add a regression test for the forwarded-file use-after-free
2026-09-15 09:55:34 +02:00
Laurent Trinques 1e124450f2 Merge pull request #875 from ispyisail/fix/keyboard-reachable-drawing-tools
Put the drawing tools in a menu so they can be used without a mouse
2026-09-15 09:51:23 +02:00
Laurent Trinques 4d70fcf35c Merge pull request #877 from ispyisail/fix/f10-opens-menubar
Open the menu bar on F10, the key people expect
2026-09-15 09:46:24 +02:00
ispyisail 55c2c0df9d Paste under the cursor and let it be positioned before it lands
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>
2026-09-15 17:07:15 +12:00
ispyisail b94b244919 Correct the comment: say what was measured, not what was assumed
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
2026-09-15 15:51:24 +12:00
ispyisail bd6bed8d61 Open the menu bar on F10, and add a test that can answer whether it works
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>
2026-09-15 13:44:59 +12:00
ispyisail 6ed26358e8 Give the keyboard the folio's context menu, not an item's generic one
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>
2026-09-15 13:03:01 +12:00
ispyisail 6a2b3973bc Put the drawing tools in a menu so they can be reached without a mouse
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>
2026-09-15 11:45:51 +12:00
ispyisail d9db6e59e7 Let Escape step back out of the folio, so Tab cannot trap keyboard users
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>
2026-09-15 11:32:42 +12:00
ispyisail 22469813fe Register the two new selection actions with ShortcutManager
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>
2026-09-15 09:32:50 +12:00
ispyisail 88823ea35f Diagram: Tab/Shift+Tab item-selection cycling + select-all-conductors/text-fields (#574)
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>
2026-09-15 09:32:50 +12:00
ispyisail a106395679 Add Linux CI: build, unit tests, and the IPC regression gate
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>
2026-09-15 07:38:24 +12:00
ispyisail 9363e9bc2e Add a regression test for the forwarded-file use-after-free
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>
2026-09-15 07:09:16 +12:00
Kellermorph a5fe544415 Fix text position shift when duplicating diagram pages
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.
2026-09-14 20:38:15 +02:00
Kellermorph 9bfe18807f Fix undo not restoring PLC slave fields and stale connections on position change
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.
2026-09-14 16:19:37 +02:00
Laurent Trinques 199444b6db Merge pull request #862 from ispyisail/fix/bugtracker-108-junction-dot-width
Fix bugtracker #108: the junction dot vanishes on a wide conductor
2026-09-14 13:25:13 +02:00
Laurent Trinques bdd52a0e2b Update ca translations, thanks Antoni 2026-09-14 13:06:16 +02:00
Laurent Trinques 86ce8fcd92 git submodule update --remote elements 2026-09-14 13:04:02 +02:00
Laurent Trinques 428687ee4b Update ca translations, thanks Antoni 2026-09-14 12:45:28 +02:00
Laurent Trinques 512d74c745 Merge pull request #868 from ispyisail/fix/ipc-open-deferred
Fix a use-after-free: forwarded files are opened inside the socket handler
2026-09-14 12:37:11 +02:00
ispyisail 561b9c4eb1 Fix indentation of the deferred-open comment block
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>
2026-09-14 22:14:36 +12:00
Kellermorph 00e75604b0 Clear stale xref data from PLC slave elementInformations on unlink and position change 2026-09-14 12:04:17 +02:00
Laurent Trinques ceda1e082a Merge pull request #861 from ispyisail/fix/bugtracker-248-split-with-spaces
Partial fix for bugtracker #248: second-instance file arguments are lost
2026-09-14 11:31:26 +02:00