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