wiRedPanda
Logic Circuit Simulator
Loading...
Searching...
No Matches
Workspace.cpp
Go to the documentation of this file.
1// Copyright 2015 - 2026, GIBIS-UNIFESP and the wiRedPanda contributors
2// SPDX-License-Identifier: GPL-3.0-or-later
3
5
6#include <algorithm>
7
8#include <QHBoxLayout>
9#include <QMessageBox>
10#include <QSaveFile>
11#include <QScrollBar>
12#include <QStandardPaths>
13#include <QUuid>
14
16#include "App/Core/Common.h"
19#include "App/Core/Settings.h"
21#include "App/Element/IC.h"
23#include "App/IO/FileUtils.h"
26#include "App/IO/VersionInfo.h"
27#include "App/Scene/Commands.h"
31#include "App/Versions.h"
33#include "App/Wiring/Port.h"
34
35namespace {
36
42bool isReadOnlyFailure(QFileDevice::FileError error)
43{
44 return error == QFileDevice::PermissionsError
45 || error == QFileDevice::OpenError
46 || error == QFileDevice::WriteError;
47}
48
49} // namespace
50
51WorkSpace::WorkSpace(QWidget *parent)
52 : QWidget(parent)
53{
54 m_view.setCacheMode(QGraphicsView::CacheBackground);
55 m_view.setScene(&m_scene);
56 // Back-pointer lets the scene query the view (e.g., for zoom level in drawBackground)
57 m_scene.setView(&m_view);
58 m_scene.setSceneRect(m_view.rect());
59 setLayout(new QHBoxLayout());
60 layout()->addWidget(&m_view);
61
62 // Minimap overview: small widget overlayed on the workspace so it remains
63 // static even when the view's zoom changes. Positioning is computed
64 // relative to the view geometry in resizeEvent().
65 m_minimap = new MinimapWidget(&m_scene, &m_view, this);
66 m_minimap->setObjectName("minimap");
67 m_minimap->raise();
68 connect(m_minimap, &MinimapWidget::geometryChangeFinished, this, &WorkSpace::onMinimapGeometryChangeFinished);
69
70 // Adjust the scene rect after every zoom so that all items remain reachable
71 // via panning, even when zoomed in very close
72 connect(&m_view, &GraphicsView::zoomChanged, &m_scene, &Scene::resizeScene);
73
74 // Coalesce bursts of changes into one autosave write — a multi-element
75 // paste, drag-rotate, or rapid typing in a label otherwise spams the
76 // disk and widens the window for partial-write corruption.
77 m_autosaveDebounceTimer.setSingleShot(true);
78 m_autosaveDebounceTimer.setInterval(500);
79 connect(&m_autosaveDebounceTimer, &QTimer::timeout, this, &WorkSpace::autosave);
80 connect(&m_scene, &Scene::circuitHasChanged, &m_autosaveDebounceTimer, qOverload<>(&QTimer::start));
81
85 connect(m_scene.undoStack(), &QUndoStack::cleanChanged, this, [this](bool /*clean*/) {
86 emit fileChanged(m_fileInfo);
87 });
88
89 setAutosaveFileName();
90
91 m_scene.setLastId(m_lastId);
92}
93
95{
102 blockSignals(true);
103
112 delete m_minimap;
113 m_minimap = nullptr;
114
118 m_autosaveDebounceTimer.stop();
119 if (!m_autosaveFileName.isEmpty()) {
120 QStringList autosaves = Settings::autosaveFiles();
121 autosaves.removeAll(m_autosaveFileName);
123 QFile::remove(m_autosaveFileName);
124 m_autosaveFileName.clear();
125 }
126}
127
128void WorkSpace::resizeEvent(QResizeEvent *event)
129{
130 QWidget::resizeEvent(event);
131
132 // Guard with isVisible(): MainWindow restores its geometry (including queuing a maximized
133 // state) before this tab is ever created, so this widget's very first resize fires while
134 // it's still not genuinely on screen -- carrying the pre-maximize "normal" size, not the
135 // final one. Consuming applyMinimapGeometry()'s one-time restore against that stale size
136 // would lose the persisted position for the rest of the session (subsequent resizes only
137 // re-clamp, they don't re-read Settings). Skipping it here lets the real, later resize --
138 // once the window manager actually applies the maximized geometry -- do the restore
139 // instead. showEvent() below covers windows that never resize again after becoming visible.
140 if (m_minimap && isVisible())
141 applyMinimapGeometry();
142
143 if (m_exerciseOverlay && m_exerciseOverlay->isVisible())
144 m_exerciseOverlay->repositionToParent();
145}
146
147void WorkSpace::showEvent(QShowEvent *event)
148{
149 QWidget::showEvent(event);
150
151 // Backstop for windows that never resize again after becoming visible (e.g. a
152 // non-maximized launch, where restoreGeometry() already applied the final size before
153 // show()) -- resizeEvent() above would otherwise never get a chance to restore at all.
154 // Deferred briefly so a genuine maximize resize (handled above) wins the race when it's
155 // fast enough; applyMinimapGeometry() only restores once (m_minimapPositioned), so a
156 // redundant call here just re-clamps the already-correct geometry.
157 if (m_minimap)
158 QTimer::singleShot(100, this, [this] { applyMinimapGeometry(); });
159}
160
162{
163 if (!m_minimap) return;
164 m_minimap->setVisible(visible);
165}
166
167void WorkSpace::applyMinimapGeometry()
168{
169 if (!m_minimap)
170 return;
171
172 const int margin = 12;
173 const QRect viewGeom = m_view.geometry();
174
175 if (!m_minimapPositioned) {
176 m_minimapPositioned = true;
177
178 const QRect restored = Settings::minimapGeometry();
179 if (restored.isValid()) {
180 // Clamp size before position: a geometry persisted from a larger window/monitor
181 // could otherwise still overflow, or push the position clamp negative.
182 const int maxWidth = qMax(m_minimap->minimumWidth(), viewGeom.width() - 2 * margin);
183 const int maxHeight = qMax(m_minimap->minimumHeight(), viewGeom.height() - 2 * margin);
184 const int width = qBound(m_minimap->minimumWidth(), restored.width(), maxWidth);
185 const int height = qBound(m_minimap->minimumHeight(), restored.height(), maxHeight);
186 const int x = qBound(margin, restored.x(), viewGeom.width() - width - margin);
187 const int y = qBound(margin, restored.y(), viewGeom.height() - height - margin);
188 m_minimap->setGeometry(x, y, width, height);
189 return;
190 }
191
192 // No persisted geometry (first launch, or never moved/resized): default to the
193 // widget's own default size, anchored bottom-right.
194 const int x = viewGeom.right() - m_minimap->width() - margin;
195 const int y = viewGeom.bottom() - m_minimap->height() - margin;
196 m_minimap->move(qMax(x, margin), qMax(y, margin));
197 return;
198 }
199
200 // Subsequent resizes: re-clamp the minimap's own current geometry into the new bounds.
201 // Deliberately does not re-read Settings -- that's only the persisted copy, refreshed on
202 // user-driven moves/resizes (onMinimapGeometryChangeFinished()); re-reading it here on
203 // every window resize would stomp legitimate in-session geometry with a stale value.
204 const QRect current = m_minimap->geometry();
205 const int maxWidth = qMax(m_minimap->minimumWidth(), viewGeom.width() - 2 * margin);
206 const int maxHeight = qMax(m_minimap->minimumHeight(), viewGeom.height() - 2 * margin);
207 const int width = qBound(m_minimap->minimumWidth(), current.width(), maxWidth);
208 const int height = qBound(m_minimap->minimumHeight(), current.height(), maxHeight);
209 const int x = qBound(margin, current.x(), viewGeom.width() - width - margin);
210 const int y = qBound(margin, current.y(), viewGeom.height() - height - margin);
211 m_minimap->setGeometry(x, y, width, height);
212}
213
214void WorkSpace::onMinimapGeometryChangeFinished(const QRect &geometry)
215{
217}
218
220{
221 return &m_scene;
222}
223
225{
226 return &m_scene;
227}
228
230{
231 return &m_view;
232}
233
235{
236 return m_scene.simulation();
237}
238
240{
241 return !m_loadedVersion.isNull() && m_loadedVersion > FormatRev::current;
242}
243
244QFileInfo WorkSpace::fileInfo() const
245{
246 return m_fileInfo;
247}
248
250{
251 sentryBreadcrumb("file", QStringLiteral("Save: %1").arg(fileName));
252
253 // The user save supersedes any pending autosave; cancel it so the timer
254 // doesn't fire after we've removed the autosave file and re-create it.
255 m_autosaveDebounceTimer.stop();
256
257 if (isFromNewerVersion()) {
259 const QString message = tr("This file was saved with a newer file format (version %1).\n"
260 "Your wiRedPanda version (%2) supports file format %3.\n\n"
261 "Please update wiRedPanda to save changes to this file.")
262 .arg(m_loadedVersion.toString(), AppVersion::current.toString(), FormatRev::current.toString());
263 QMessageBox::warning(this, tr("Cannot save."), message);
264 }
265 return SaveOutcome::Saved;
266 }
267
268 if (m_isInlineIC) {
269 if (!m_parentWorkspace) {
270 qCWarning(zero) << "Inline IC tab: parent workspace was closed. Save is a no-op.";
271 return SaveOutcome::Saved;
272 }
273
274 // Inline-IC tabs serialize to a blob and emit a signal instead of writing to disk.
275 const QString contextDir = m_scene.contextDir();
276
277 // Embed any file-backed ICs so the blob is self-contained
278 for (auto *elm : m_scene.elements()) {
279 if (elm->elementType() == ElementType::IC && !elm->isEmbedded()) {
280 auto *ic = static_cast<IC *>(elm);
281 const QString icFile = ic->file();
282 const QString baseName = QFileInfo(icFile).baseName();
283 if (!m_scene.icRegistry()->hasBlob(baseName)) {
284 QFileInfo fi(QDir(contextDir), icFile);
285 QFile f(fi.absoluteFilePath());
286 // setBlobName() below marks the IC embedded unconditionally; if the
287 // dependency can't actually be read, embedding it anyway would leave the
288 // blob metadata pointing at a name never registered in the IC registry —
289 // and, since isEmbedded() would then always be true, this block would never
290 // retry on any later save either. Fail loudly now instead of producing a
291 // blob that throws "not found" only when someone tries to reload it.
292 if (!fi.exists() || !f.open(QIODevice::ReadOnly)) {
293 throw PANDACEPTION("Cannot save: sub-circuit \"%1\" could not be read to embed it.", icFile);
294 }
295 m_scene.icRegistry()->registerBlob(baseName, f.readAll());
296 }
297 // Switch the IC to blob-backed for serialization; do NOT call
298 // loadFromBlob. The IC already has its internal state loaded
299 // from the same file we just registered as a blob, so a reload
300 // would only destroy and rebuild ports — which cascade-deletes
301 // every scene wire connected to the IC (silent data loss) and
302 // races the simulation tick when play is running (H2-shape crash).
303 ic->setBlobName(baseName);
304 }
305 }
306
307 // Serialize as a full .panda file (header + compressed metadata + elements)
308 QMap<QString, QVariant> metadata;
309 Serialization::serializeBlobRegistry(m_scene.icRegistry()->blobMap(), metadata);
310
311 QByteArray payload;
312 QDataStream payloadStream(&payload, QIODevice::WriteOnly);
313 payloadStream.setVersion(QDataStream::Qt_5_12);
314 payloadStream << metadata;
315 Serialization::serialize(m_scene.items(), payloadStream, {.purpose = SerializationPurpose::PortableFile});
316
317 QByteArray blob;
318 QDataStream stream(&blob, QIODevice::WriteOnly);
320 Serialization::writePayload(stream, payload);
321
322 m_scene.undoStack()->setClean();
323 emit icBlobSaved(m_parentICElementId, blob);
324 return SaveOutcome::Saved;
325 }
326
327 Q_ASSERT_X(!fileName.isEmpty() && fileName.endsWith(".panda"),
328 "WorkSpace::save", "caller must resolve a non-empty, .panda-suffixed path first");
329 const QString &fileName_ = fileName;
330
331 qCDebug(zero) << "FileName: " << fileName_;
332
333 // Copy external file dependencies (appearances, audio, IC sub-circuits, waveform)
334 // to the new directory before updating contextDir, so save() can store bare filenames.
335 // Always run this, even on a brand-new project's first save or a re-save to the same
336 // directory: copyToDir()/copyPandaFile() are no-ops for missing/self/already-present
337 // files, so this is a cheap "ensure every dependency is present" pass every time --
338 // including when a new dependency was added since the last save to an already-saved project.
339 const QString oldContextDir = m_scene.contextDir();
340 const QString newContextDir = QFileInfo(fileName_).absolutePath();
341 for (auto *elm : m_scene.elements()) {
342 for (const QString &file : elm->externalFiles()) {
343 if (file.endsWith(".panda")) {
344 // copyPandaFile copies the file itself and recursively copies any
345 // fileBackedICs it references (resolved against the source file's own
346 // directory, not oldContextDir -- a dependency freshly added from an
347 // arbitrary external location never lived in oldContextDir), in one pass.
348 const QFileInfo srcInfo(file);
349 Serialization::copyPandaFile(srcInfo, QFileInfo(newContextDir + "/" + srcInfo.fileName()));
350 } else {
351 FileUtils::copyToDir(file, newContextDir);
352 }
353 }
354 }
355
356 // Copy the associated BeWavedDolphin waveform file if present
357 if (!m_dolphinFileName.isEmpty()) {
358 const QString resolved = QDir(oldContextDir).absoluteFilePath(m_dolphinFileName);
359 FileUtils::copyToDir(resolved, newContextDir);
360 }
361
362 // QSaveFile writes to a temp file and commits atomically, preventing data loss
363 // if the process is interrupted during a write
364 QSaveFile saveFile(fileName_);
365
366 if (!saveFile.open(QIODevice::WriteOnly)) {
367 // OneDrive lock, ZIP-extracted folder, network drive, write-protected attribute.
368 // Reported only in interactive mode, where WorkspaceManager can re-prompt for a
369 // writable location; non-interactive callers (CLI batch, MCP, tests) have no one
370 // to show a dialog to, so this throws there exactly as any other I/O error would.
371 if (Application::interactiveMode && isReadOnlyFailure(saveFile.error())) {
373 }
374 throw PANDACEPTION("Error opening file: %1", saveFile.errorString());
375 }
376
377 // Re-tighten the scene rect (avoids a viewport jump on element selection). Must go
378 // through resizeScene() rather than a local computation: producing even a slightly
379 // different rect than resizeScene()'s (quantized) one would ping-pong the scene rect
380 // between two values across edit/autosave cycles, and every change makes Qt's BSP
381 // index re-insert all items. No drag can be in progress during a save, so this takes
382 // the same tighten branch the interactive callers use.
383 m_scene.resizeScene();
384
385 QDataStream stream(&saveFile);
387 save(stream);
388
389 if (!saveFile.commit()) {
390 // Covered: TestWorkspaceUnit::testSaveReturnsReadOnlyTargetWhenCommitFailsInteractive()/
391 // testSaveThrowsWhenCommitFailsNonInteractive() force this via RLIMIT_FSIZE
392 // (ScopedTinyFsizeLimit) -- Qt defers write() errors, so a failed write() above only
393 // surfaces here, at commit(), as QFileDevice::WriteError (which isReadOnlyFailure()
394 // treats the same as an open()-time permission failure).
395 if (Application::interactiveMode && isReadOnlyFailure(saveFile.error())) {
397 }
398 throw PANDACEPTION("Could not save file: %1", saveFile.errorString());
399 }
400
401 // Only adopt the new file/context identity once it's actually on disk — setting it earlier
402 // left the workspace believing its current file was a path that was never written whenever
403 // the open/commit above failed (interactive ReadOnlyTarget with the retry cancelled, or a
404 // throw in non-interactive/MCP callers), with nothing to roll it back afterward.
405 setCurrentFile(fileName_);
406
407 // Mark the undo stack as clean so the title bar no longer shows unsaved-change indicator
408 m_scene.undoStack()->setClean();
409
410 // Clean up this workspace's own tracked autosave file now that it has a real save.
411 if (!m_autosaveFileName.isEmpty() && QFile::exists(m_autosaveFileName)) {
412 qCDebug(zero) << "Remove autosave from settings and delete it.";
413 QStringList autosaves = Settings::autosaveFiles();
414 autosaves.removeAll(m_autosaveFileName);
416 QFile::remove(m_autosaveFileName);
417 m_autosaveFileName.clear();
418 qCDebug(zero) << "All auto save file names after removing autosave: " << autosaves;
419 }
420
421 emit fileChanged(m_fileInfo);
422 return SaveOutcome::Saved;
423}
424
425void WorkSpace::save(QDataStream &stream)
426{
427 // Metadata section: all file-level fields stored as key-value pairs.
428 QMap<QString, QVariant> metadata;
429
430 if (!m_dolphinFileName.isEmpty()) {
431 metadata["dolphinFileName"] = m_dolphinFileName;
432 }
433
434 // Extract port metadata from Input/Output elements in the scene,
435 // sorted by Y/X position to match IC runtime port order.
436 const auto portMeta = IC::buildPortMetadata(m_scene.elements());
437 if (portMeta.inputCount > 0 || portMeta.outputCount > 0) {
438 metadata["inputCount"] = portMeta.inputCount;
439 metadata["outputCount"] = portMeta.outputCount;
440 metadata["inputLabels"] = portMeta.inputLabels;
441 metadata["outputLabels"] = portMeta.outputLabels;
442 }
443
444 Serialization::serializeBlobRegistry(m_scene.icRegistry()->blobMap(), metadata);
445
446 // Collect unique file-backed IC filenames for copyFiles (Save As).
447 QStringList fileBackedICs;
448 for (auto *elm : m_scene.elements()) {
449 if (elm->elementType() == ElementType::IC && !elm->isEmbedded()) {
450 const QString icFile = static_cast<IC *>(elm)->file();
451 if (!icFile.isEmpty() && !fileBackedICs.contains(QFileInfo(icFile).fileName())) {
452 fileBackedICs.append(QFileInfo(icFile).fileName());
453 }
454 }
455 }
456 if (!fileBackedICs.isEmpty()) {
457 metadata["fileBackedICs"] = fileBackedICs;
458 }
459
460 // Metadata + elements + connections are serialized into an in-memory buffer
461 // first, then compressed as one unit into the real stream -- see
462 // Serialization::writePayload().
463 QByteArray payload;
464 QDataStream payloadStream(&payload, QIODevice::WriteOnly);
465 payloadStream.setVersion(QDataStream::Qt_5_12);
466
467 payloadStream << metadata;
468 Serialization::serialize(m_scene.items(), payloadStream, {.purpose = SerializationPurpose::PortableFile});
469
470 Serialization::writePayload(stream, payload);
471}
472
473void WorkSpace::load(const QString &fileName)
474{
475 sentryBreadcrumb("file", QStringLiteral("Load: %1").arg(fileName));
476
477 QFile file(fileName);
478
479 if (!file.exists()) {
480 qCDebug(zero) << "This file does not exist: " << fileName;
481 throw PANDACEPTION("This file does not exist: %1", fileName);
482 }
483
484 setCurrentFile(fileName);
485
486 qCDebug(zero) << "File exists.";
487
488 if (!file.open(QIODevice::ReadOnly)) {
489 qCDebug(zero) << "Could not open file: " << file.errorString();
490 throw PANDACEPTION("Could not open file: %1", file.errorString());
491 }
492
493 QDataStream stream(&file);
494 QVersionNumber version = Serialization::readPandaHeader(stream);
495 m_loadedVersion = version;
496
497 bool needsMigration = (version < FormatRev::current) && Application::migrationEnabled;
498 if (needsMigration) {
499 createVersionedBackup(fileName, version);
500 }
501
502 load(stream, version, QFileInfo(fileName).absolutePath());
503 file.close();
504
505 if (needsMigration) {
506 // save() now requires an already-.panda-suffixed path; fileName came from an
507 // existing, successfully-opened file, so in practice it already has one, but
508 // keep the same defensive fallback save() itself used to apply internally.
509 QString migratedFileName = fileName;
510 if (!migratedFileName.endsWith(".panda")) {
511 migratedFileName.append(".panda");
512 }
513 // Re-save in new format. This is an automatic background step, not a user-
514 // initiated save, so a read-only target (unlike an explicit Save command) isn't
515 // worth an interactive re-prompt here -- just leave the on-disk file in its
516 // original format and note why the migration didn't stick.
517 if (save(migratedFileName) == SaveOutcome::ReadOnlyTarget) {
518 qCWarning(zero) << "Could not migrate" << migratedFileName << "to the current format: target is read-only.";
519 }
520 }
521
522 emit fileChanged(m_fileInfo);
523}
524
525void WorkSpace::load(QDataStream &stream, const QVersionNumber &version, const QString &contextDir)
526{
527 qCDebug(zero) << "Loading file.";
528 // Block simulation updates while items are being added to avoid intermediate
529 // partial-topology updates that could crash or produce incorrect output
530 SimulationBlocker simulationBlocker(m_scene.simulation());
531 qCDebug(zero) << "Stopped simulation.";
532 qCDebug(zero) << "Version: " << version;
533
535 if (version > FormatRev::current) {
536 const QString fmtVersion = FormatRev::current.toString();
537 const QString fileVersion = version.toString();
538 const QString message = tr("This file was saved with a newer file format (version %1).\n"
539 "Your version supports file format %2.\n\n"
540 "The file will be opened but saving is blocked.\n"
541 "Please update wiRedPanda to edit and save this file.")
542 .arg(fileVersion, fmtVersion);
543 QMessageBox::warning(this, tr("Newer version file."), message);
544 } else if (version < FormatRev::current) {
545 const QString backupFileName = m_fileInfo.completeBaseName() + ".v" + version.toString() + "." + m_fileInfo.suffix();
546 const QString message = tr("This file is in an older format (version %1) and will be automatically upgraded to the current format (version %2).\n"
547 "A backup of the original file has been created with name: %3")
548 .arg(version.toString(), FormatRev::current.toString(), backupFileName);
549 QMessageBox::information(this, tr("File upgraded."), message);
550 }
551 }
552
553 // Everything past the header may be zlib-compressed (Rev100+); buffer and
554 // decompress it once, then read the rest of the file from that in-memory
555 // stream instead of the live device (which readPayload() has already fully
556 // consumed to do the decompression).
557 QByteArray payload = Serialization::readPayload(stream, version);
558 QDataStream payloadStream(&payload, QIODevice::ReadOnly);
559 payloadStream.setVersion(QDataStream::Qt_5_12);
560
561 // V4.6+ stores all file-level fields in the metadata map.
562 // Older versions wrote dolphinFileName and sceneRect positionally before the map.
563 QMap<QString, QVariant> metadata;
564 if (VersionInfo::hasUnifiedMetadata(version)) {
565 metadata = Serialization::readBoundedMetadata(payloadStream);
566 m_dolphinFileName = metadata.value("dolphinFileName").toString();
567 } else {
568 m_dolphinFileName = Serialization::loadDolphinFileName(payloadStream, version);
569 Serialization::loadRect(payloadStream, version);
570 if (VersionInfo::hasMetadata(version)) {
571 metadata = Serialization::readBoundedMetadata(payloadStream);
572 }
573 }
574 qCDebug(zero) << "Dolphin name: " << m_dolphinFileName;
575
576 QMap<QString, QByteArray> blobRegistry = Serialization::deserializeBlobRegistry(metadata, version);
577
578 // Populate the scene's IC registry with embedded IC blobs
579 for (auto it = blobRegistry.cbegin(); it != blobRegistry.cend(); ++it) {
580 m_scene.icRegistry()->setBlob(it.key(), it.value());
581 }
582
583 QHash<quint64, Port *> portMap;
584 if (!contextDir.isEmpty()) {
585 m_scene.setContextDir(contextDir);
586 }
587 auto context = m_scene.deserializationContext(portMap, version, SerializationPurpose::PortableFile);
588 context.contextDir = contextDir;
589 const auto items = Serialization::deserialize(payloadStream, context);
590 qCDebug(zero) << "Finished loading items.";
591
592 for (auto *item : items) {
593 m_scene.addItem(item);
594
595 // Track the highest element ID seen so that newly created elements
596 // will receive IDs that don't collide with those just loaded
597 if (auto *ge = qgraphicsitem_cast<GraphicElement *>(item)) {
598 m_lastId = (std::max)(m_lastId, ge->id());
599 }
600 }
601
602 m_scene.setLastId(m_lastId);
603
604 m_scene.setSceneRect(m_scene.itemsBoundingRect());
605
606 qCDebug(zero) << "Finished loading file.";
607}
608
609void WorkSpace::setDolphinFileName(const QString &fileName)
610{
611 m_dolphinFileName = fileName;
612}
613
615{
616 return m_dolphinFileName;
617}
618
619void WorkSpace::setAutosaveFileName()
620{
621 // Eagerly ensure the global autosaves directory exists; the actual filename
622 // is computed lazily inside autosave() when the workspace first turns dirty.
623 QDir autosavePath(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/autosaves");
624 if (!autosavePath.exists()) {
625 autosavePath.mkpath(autosavePath.absolutePath()); // LCOV_EXCL_LINE — this is called from every WorkSpace constructor, so the global autosaves directory is already created by whichever WorkSpace-based test runs first in the process; a Meyer's-singleton-style one-time-only branch (pattern 12) that can't be isolated without disrupting shared state other tests depend on.
626 }
627 m_autosaveFileName.clear();
628}
629
631{
632 return m_lastId;
633}
634
635void WorkSpace::setLastId(int newLastId)
636{
637 m_lastId = newLastId;
638}
639
640void WorkSpace::autosave()
641{
642 if (m_isInlineIC) {
643 return; // Inline IC tabs don't autosave to disk
644 }
645
646 if (isFromNewerVersion()) {
647 return; // Autosaving a newer-version file would lose data
648 }
649
650 qCDebug(two) << "Starting autosave.";
651 QStringList autosaves = Settings::autosaveFiles();
652 qCDebug(three) << "All auto save file names before autosaving: " << autosaves;
653
654 auto *undoStack = m_scene.undoStack();
655 qCDebug(zero) << "Undo stack element: " << undoStack->index() << " of " << undoStack->count();
656
657 // If the undo stack is clean the project has no unsaved changes, so there's
658 // nothing to protect; delete any leftover autosave file and bail out.
659 if (undoStack->isClean()) {
660 qCDebug(three) << "Undo stack is clean.";
661 if (!m_autosaveFileName.isEmpty()) {
662 autosaves.removeAll(m_autosaveFileName);
664 QFile::remove(m_autosaveFileName);
665 m_autosaveFileName.clear();
666 }
667 emit fileChanged(m_fileInfo);
668 return;
669 }
670
671 qCDebug(three) << "Undo is !clean. Must set autosave file.";
672
673 // Choose the directory to write into: unsaved projects go to the global
674 // autosaves dir, saved projects go next
675 // to their .panda file unless that directory is read-only OR is the bundled
676 // Examples directory. Writing beside the file is deliberate (an autosave stays
677 // with the project it protects), but the bundled examples are shipped content,
678 // not the user's project: an installed copy is usually read-only and already
679 // took the fallback, while a dev checkout's Examples/ is writable and would
680 // otherwise accumulate hidden .<name>.<uuid>.panda files that .gitignore
681 // explicitly un-ignores. Recovery reads absolute paths out of
682 // Settings::autosaveFiles(), so relocating the file changes nothing for it.
683 QDir path;
684 const QString globalAutosaves =
685 QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/autosaves";
686 if (m_fileInfo.fileName().isEmpty()) {
687 path.setPath(globalAutosaves);
688 } else {
689 const QString projectDir = m_fileInfo.absolutePath();
690 const QFileInfo dirInfo(projectDir);
691 if (dirInfo.isWritable() && !InstallRelativePaths::isCandidate(QStringLiteral("Examples"), projectDir)) {
692 path.setPath(projectDir);
693 } else {
694 path.setPath(globalAutosaves);
695 }
696 }
697 if (!path.exists()) {
698 path.mkpath(path.absolutePath()); // LCOV_EXCL_LINE — QFileInfo::isWritable() on a not-yet-created directory returns false, so the "project dir" branch above only ever selects a directory that already exists; the AppData fallback is the same already-created-by-setAutosaveFileName() path as the pattern-12 exclusion above. Not reachable without disrupting shared process-wide state.
699 }
700 qCDebug(three) << "Autosavepath: " << path.absolutePath();
701
702 // Reuse a stable filename across writes so QSaveFile can replace the same
703 // target atomically. If the project's directory has changed (Save As to a
704 // new path), drop the old autosave file in the previous location first.
705 const QString prefix = m_fileInfo.fileName().isEmpty() ? QStringLiteral(".") : "." + m_fileInfo.baseName() + ".";
706 if (!m_autosaveFileName.isEmpty() && QFileInfo(m_autosaveFileName).absolutePath() != path.absolutePath()) {
707 autosaves.removeAll(m_autosaveFileName);
708 QFile::remove(m_autosaveFileName);
709 m_autosaveFileName.clear();
710 }
711 if (m_autosaveFileName.isEmpty()) {
712 const QString tag = QUuid::createUuid().toString(QUuid::Id128);
713 m_autosaveFileName = path.absoluteFilePath(prefix + tag + ".panda");
714 }
715
716 // Drop the previous registry entry before writing so we don't double-list it.
717 if (autosaves.contains(m_autosaveFileName)) {
718 autosaves.removeAll(m_autosaveFileName);
719 Settings::setAutosaveFiles(autosaves);
720 }
721
722 qCDebug(three) << "Writing to autosave file.";
723 // QSaveFile writes to a sibling temp file and atomically renames on commit,
724 // truncating any prior contents. Both partial-write corruption (process
725 // killed mid-write) and shrink-leftover-tail corruption (new circuit
726 // shorter than the previous autosave) are eliminated.
727 QSaveFile autosaveFile(m_autosaveFileName);
728 if (!autosaveFile.open(QIODevice::WriteOnly)) {
729 throw PANDACEPTION("Error opening autosave file: %1", autosaveFile.errorString());
730 }
731
732 QDataStream stream(&autosaveFile);
734 save(stream);
735
736 if (!autosaveFile.commit()) {
737 // Covered: TestWorkspaceUnit::testAutosaveThrowsWhenCommitFails() forces this via
738 // RLIMIT_FSIZE (ScopedTinyFsizeLimit) -- Qt defers write() errors, so a failed
739 // write() above only surfaces here, at commit().
740 throw PANDACEPTION("Could not commit autosave file: %1", autosaveFile.errorString());
741 }
742
743 autosaves.append(m_autosaveFileName);
745
746 qCDebug(three) << "All auto save file names after adding autosave: " << autosaves;
747
748 emit fileChanged(m_fileInfo);
749}
750
752{
753 // Crash-recovery flow: a recovered autosave is being treated as if it
754 // came from the original .panda file, so the next autosave write should
755 // overwrite the project file directly rather than spawning a new
756 // hidden temp file next to it. Surprising semantically but intentional.
757 m_autosaveFileName = m_fileInfo.filePath();
758}
759
761{
762 if (m_autosaveDebounceTimer.isActive()) {
763 m_autosaveDebounceTimer.stop();
764 autosave();
765 }
766}
767
768void WorkSpace::createVersionedBackup(const QString &fileName, const QVersionNumber &version)
769{
770 Serialization::createVersionedBackup(fileName, version);
771}
772
773void WorkSpace::loadFromBlob(const QByteArray &blob, WorkSpace *parent, int icElementId, const QString &parentContextDir)
774{
775 SimulationBlocker simulationBlocker(m_scene.simulation());
776
777 if (!parentContextDir.isEmpty()) {
778 m_scene.setContextDir(parentContextDir);
779 }
780
781 // Blob is a full .panda file
782 QByteArray blobData(blob);
783 QDataStream stream(&blobData, QIODevice::ReadOnly);
784 auto preamble = Serialization::readPreamble(stream);
785
786 const auto blobRegistry = Serialization::deserializeBlobRegistry(preamble.metadata, preamble.version);
787 for (auto it = blobRegistry.cbegin(); it != blobRegistry.cend(); ++it) {
788 m_scene.icRegistry()->setBlob(it.key(), it.value());
789 }
790
791 QHash<quint64, Port *> portMap;
792 auto context = m_scene.deserializationContext(portMap, preamble.version, SerializationPurpose::PortableFile);
793 context.contextDir = parentContextDir;
794 QDataStream elementsStream(&preamble.remainingPayload, QIODevice::ReadOnly);
795 elementsStream.setVersion(QDataStream::Qt_5_12);
796 const auto items = Serialization::deserialize(elementsStream, context);
797
798 for (auto *item : items) {
799 m_scene.addItem(item);
800
801 if (auto *ge = qgraphicsitem_cast<GraphicElement *>(item)) {
802 m_lastId = (std::max)(m_lastId, ge->id());
803 }
804 }
805
806 m_scene.setLastId(m_lastId);
807 m_scene.setSceneRect(m_scene.itemsBoundingRect());
808
809 // Set inline-IC mode after successful deserialization
810 m_isInlineIC = true;
811 m_parentWorkspace = parent;
812 m_parentICElementId = icElementId;
813
814 // Derive blob name from the parent IC element
815 if (parent) {
816 if (auto *item = parent->scene()->itemById(icElementId)) {
817 if (auto *elm = dynamic_cast<GraphicElement *>(item)) {
818 m_inlineBlobName = elm->blobName();
819 }
820 }
821 }
822}
823
824void WorkSpace::onChildICBlobSaved(int icElementId, const QByteArray &blob)
825{
826 auto *item = m_scene.itemById(icElementId);
827 if (!item) {
828 return; // Orphaned child tab — IC was deleted or undone
829 }
830
831 auto *elm = dynamic_cast<GraphicElement *>(item);
832 if (!elm || !elm->isEmbedded()) {
833 return;
834 }
835
836 const QString targetBlobName = elm->blobName();
837 const auto targets = m_scene.icRegistry()->findICsByBlobName(targetBlobName);
838 if (targets.isEmpty()) { // LCOV_EXCL_LINE — elm itself (just confirmed isEmbedded() with this exact blobName, and already resolved as a live scene element above) satisfies findICsByBlobName()'s own identical isEmbedded()+blobName-match scan, so targets always contains at least elm.
839 return; // LCOV_EXCL_LINE — see above.
840 }
841
842 const auto connections = UpdateBlobCommand::captureConnections(targets);
843
844 SimulationBlocker simulationBlocker(m_scene.simulation());
845
846 const QByteArray oldData = ICRegistry::captureSnapshot(targets);
847 QByteArray oldBlob = m_scene.icRegistry()->blob(targetBlobName);
848
849 // Update the registry and reload all targets atomically: if any loadFromBlob
850 // throws, roll back every element that was already updated so we don't push a
851 // partial undo command (mirrors the pattern in ICRegistry::embedICsByFile).
852 m_scene.icRegistry()->setBlob(targetBlobName, blob);
853 QList<GraphicElement *> updated;
854 try {
855 for (auto *target : targets) {
856 auto *ic = static_cast<IC *>(target);
857 ic->loadFromBlob(blob, m_scene.contextDir());
858 updated.append(target);
859 }
860 } catch (...) {
861 ICRegistry::rollbackElements(updated, oldData, &m_scene);
862 m_scene.icRegistry()->setBlob(targetBlobName, oldBlob);
863 throw;
864 }
865
866 auto *cmd = new UpdateBlobCommand(targets, oldData, connections, &m_scene);
867 cmd->setOldBlob(oldBlob);
868 m_scene.undoStack()->push(cmd);
869}
870
871void WorkSpace::removeEmbeddedIC(const QString &blobName)
872{
873 QList<QGraphicsItem *> toDelete;
874
875 for (auto *item : m_scene.items()) {
876 if (item->type() != GraphicElement::Type) {
877 continue;
878 }
879 auto *elm = qgraphicsitem_cast<GraphicElement *>(item);
880 if (elm && elm->isEmbedded() && elm->blobName() == blobName) {
881 toDelete.append(item);
882 // Also collect connections to this element
883 for (int i = 0; i < elm->inputSize(); ++i) {
884 for (auto *conn : elm->inputPort(i)->connections()) {
885 if (!toDelete.contains(conn)) {
886 toDelete.append(conn);
887 }
888 }
889 }
890 for (int i = 0; i < elm->outputSize(); ++i) {
891 for (auto *conn : elm->outputPort(i)->connections()) {
892 if (!toDelete.contains(conn)) {
893 toDelete.append(conn);
894 }
895 }
896 }
897 }
898 }
899
900 const bool hasBlob = m_scene.icRegistry()->hasBlob(blobName);
901 if (toDelete.isEmpty() && !hasBlob) {
902 return;
903 }
904
905 // Pair the IC deletion with blob removal in a single macro so undo
906 // restores both — eagerly removing the blob outside the command would
907 // leave restored ICs pointing at a registry entry that no longer exists.
908 m_scene.undoStack()->beginMacro(tr("Remove embedded IC \"%1\"").arg(blobName));
909 if (!toDelete.isEmpty()) {
910 m_scene.receiveCommand(new DeleteItemsCommand(toDelete, &m_scene));
911 }
912 if (hasBlob) {
913 m_scene.receiveCommand(new RemoveBlobCommand(blobName, &m_scene));
914 }
915 m_scene.undoStack()->endMacro();
916}
917
918void WorkSpace::setCurrentFile(const QString &filePath)
919{
920 m_fileInfo = QFileInfo(filePath);
921 m_scene.setContextDir(m_fileInfo.absolutePath());
922}
923
925{
926 m_exerciseOverlay = overlay;
927}
Custom QApplication subclass with exception handling and main-window access.
All QUndoCommand subclasses and the CommandUtils helper namespace.
Common logging utilities, the Pandaception error type, and helper macros.
#define PANDACEPTION(msg,...)
Definition Common.h:98
#define qCDebug(category)
Definition Common.h:29
Connection: a wire that connects an output port to an input port in the circuit scene.
Abstract base class for all graphical circuit elements.
IC definition registry with file watching and embedded blob storage.
Integrated Circuit (IC) graphic element that encapsulates a sub-circuit file.
Per-platform resolution of install-relative content directories.
Port classes: Port (base), InputPort, and OutputPort.
Lightweight Sentry helpers gated behind HAVE_SENTRY.
void sentryBreadcrumb(const char *category, const QString &message)
Deserialization/serialization context structs passed through load()/save() call chains.
Circuit and waveform file serialization/deserialization utilities.
Typed wrappers around QSettings for all application preferences.
RAII guard that temporarily stops the simulation while in scope.
Named version predicates for file-format compatibility checks.
File-format version constants and application version accessor.
WorkSpace widget: the complete circuit editing environment for one tab.
static bool migrationEnabled
Definition Application.h:93
static bool interactiveMode
Definition Application.h:81
Undo command that removes a list of items from the scene.
Definition Commands.h:98
Semi-transparent overlay displayed at the bottom of the canvas during a circuit exercise.
Abstract base class for all graphical circuit elements in wiRedPanda.
virtual const QString & blobName() const
Returns the blob name for embedded ICs, empty string otherwise. Base returns empty.
Extended QGraphicsView with enhanced navigation capabilities.
void zoomChanged()
Emitted whenever the zoom level changes.
static void rollbackElements(const QList< GraphicElement * > &elements, const QByteArray &snapshot, Scene *scene)
Restores elements from a previously captured snapshot (used for atomic rollback).
static QByteArray captureSnapshot(const QList< GraphicElement * > &targets)
Serializes targets into a self-contained .panda byte array (used for embedding).
Graphic element representing an Integrated Circuit (sub-circuit) box.
Definition IC.h:31
void loadFromBlob(const QByteArray &blob, const QString &contextDir)
Loads the IC from in-memory blob bytes (full .panda file format).
Definition IC.cpp:248
static PortMetadata buildPortMetadata(const QVector< GraphicElement * > &elements)
Scans elements for Input/Output groups, sorts by Y/X position, and builds labels.
Definition IC.cpp:259
const QString & file() const
Definition IC.h:73
static bool isCandidate(const QString &category, const QString &directory)
void geometryChangeFinished(const QRect &geometry)
Undo command that removes/restores a blob in the IC registry.
Definition Commands.h:474
Main circuit editing scene.
Definition Scene.h:56
void resizeScene()
Tightens the scene rect to item bounds while preserving the viewport position.
Definition Scene.cpp:635
ItemWithId * itemById(int id) const
Returns the item registered under id, or nullptr if not found.
Definition Scene.cpp:171
void circuitHasChanged()
Emitted whenever the circuit changes (element added/removed/moved).
void setContextDir(const QString &dir)
Sets the directory of the .panda file associated with this scene.
Definition Scene.h:360
static QList< QGraphicsItem * > deserialize(QDataStream &stream, SerializationContext &context)
Deserializes items from stream until the stream is exhausted.
static void serialize(const QList< QGraphicsItem * > &items, QDataStream &stream, SerializationOptions options)
Serializes items to stream in the current .panda binary format.
static void copyPandaFile(const QFileInfo &srcPath, const QFileInfo &destPath, QSet< QString > *visited=nullptr, int depth=0)
Copies a .panda file and its file-backed IC dependencies to destPath.
static void writePandaHeader(QDataStream &stream)
Writes the .panda circuit file header to stream.
static void serializeBlobRegistry(const QMap< QString, QByteArray > &blobs, QMap< QString, QVariant > &metadata)
Serializes embedded ICs into a metadata map (sets the "embeddedICs" key).
static QVersionNumber readPandaHeader(QDataStream &stream)
Reads and validates the .panda circuit file header; returns the stored version number.
static QString loadDolphinFileName(QDataStream &stream, const QVersionNumber &version)
Returns the BeWavedDolphin waveform file name stored in stream at version.
static void writePayload(QDataStream &stream, const QByteArray &payload)
Compresses payload (qCompress) and writes it to stream.
static Preamble readPreamble(QDataStream &stream)
Reads the full .panda preamble: header, dolphin filename, rect, and metadata (V_4_5+).
static QByteArray readPayload(QDataStream &stream, const QVersionNumber &version)
Reads the remainder of stream's device, decompressing it first if version indicates the compressed-pa...
static QMap< QString, QVariant > readBoundedMetadata(QDataStream &stream)
Reads the file-level metadata QMap<QString,QVariant> from stream without calling QList::reserve() wit...
static QMap< QString, QByteArray > deserializeBlobRegistry(const QMap< QString, QVariant > &metadata, const QVersionNumber &fileVersion)
Extracts the embedded IC registry from a metadata map.
static void createVersionedBackup(const QString &fileName, const QVersionNumber &version)
Copies fileName to a versioned sidecar before overwriting it during migration.
static QRectF loadRect(QDataStream &stream, const QVersionNumber &version)
Returns the canvas viewport rectangle from the last saved session.
static void setMinimapGeometry(const QRect &geometry)
Definition Settings.cpp:305
static QStringList autosaveFiles()
Definition Settings.cpp:186
static void setAutosaveFiles(const QStringList &files)
Definition Settings.cpp:191
static QRect minimapGeometry()
Definition Settings.cpp:300
RAII guard that stops the simulation on construction and restarts it on destruction.
Manages the digital circuit simulation loop.
Definition Simulation.h:39
Undo command for embedded IC blob changes that may alter port counts.
Definition Commands.h:521
static QList< ConnectionInfo > captureConnections(const QList< GraphicElement * > &targets)
Captures connection topology for all target elements before a blob operation.
void setLastId(int newLastId)
Forces the element-ID counter to newLastId.
void setDolphinFileName(const QString &fileName)
Sets the associated BeWavedDolphin waveform file path to fileName.
void icBlobSaved(int icElementId, const QByteArray &blob)
Emitted when an inline IC tab saves its blob (propagated to parent).
bool isFromNewerVersion() const
Returns true if the loaded file was saved by a newer version of wiRedPanda.
void setAutosaveFile()
Creates or replaces the autosave temporary file.
void fileChanged(const QFileInfo &fileInfo)
Emitted whenever the file info of this workspace changes (load/save).
Scene * scene()
Returns the Scene embedded in this workspace.
void onChildICBlobSaved(int icElementId, const QByteArray &blob)
Receives a saved blob from a child inline tab.
WorkSpace(QWidget *parent=nullptr)
Constructs the workspace with optional parent widget.
Definition Workspace.cpp:51
void setMinimapVisible(bool visible)
void removeEmbeddedIC(const QString &blobName)
Removes all IC instances with the given blob name.
QString dolphinFileName() const
Returns the path of the associated BeWavedDolphin waveform file.
int lastId() const
Returns the highest element ID assigned in this workspace.
~WorkSpace() override
Flushes any pending debounced autosave on destruction.
Definition Workspace.cpp:94
void flushPendingAutosave()
Forces any pending debounced autosave to run synchronously.
void setExerciseOverlay(ExerciseOverlay *overlay)
void loadFromBlob(const QByteArray &blob, WorkSpace *parent, int icElementId, const QString &parentContextDir)
Loads a blob for editing in an inline tab.
void load(const QString &fileName)
Loads a circuit from the file at fileName.
void resizeEvent(QResizeEvent *event) override
Simulation * simulation()
Returns the embedded Simulation.
GraphicsView * view()
Returns the GraphicsView embedded in this workspace.
void showEvent(QShowEvent *event) override
SaveOutcome save(const QString &fileName)
Saves the current circuit to fileName.
QFileInfo fileInfo() const
Returns the file info for the currently open circuit file.
const QVersionNumber current
Definition Versions.h:70
void copyToDir(const QString &srcPath, const QString &destDir)
Definition FileUtils.h:17
const QVersionNumber current
Definition Versions.h:63
bool hasMetadata(const QVersionNumber &v)
V4.5: File-level metadata map and embedded IC blob registry.
Definition VersionInfo.h:72
bool hasUnifiedMetadata(const QVersionNumber &v)
V4.6: Dolphin filename moved into metadata map; scene rect no longer stored.
Definition VersionInfo.h:75