diff --git a/app/dirtystatemanager.cpp b/app/dirtystatemanager.cpp new file mode 100644 index 0000000..73fd2e5 --- /dev/null +++ b/app/dirtystatemanager.cpp @@ -0,0 +1,54 @@ +#include "dirtystatemanager.h" +#include + +DirtyStateManager& DirtyStateManager::instance() { + static DirtyStateManager instance; + return instance; +} + +void DirtyStateManager::markDirty(QWidget* tab) { + if (!tab) return; + + if (!m_dirtyTabs.contains(tab)) { + m_dirtyTabs.insert(tab); + emit dirtyStateChanged(tab, true); + } +} + +void DirtyStateManager::markClean(QWidget* tab) { + if (!tab) return; + + if (m_dirtyTabs.contains(tab)) { + m_dirtyTabs.remove(tab); + emit dirtyStateChanged(tab, false); + } +} + +bool DirtyStateManager::isDirty(QWidget* tab) const { + return m_dirtyTabs.contains(tab); +} + +QList DirtyStateManager::dirtyTabs() const { + return m_dirtyTabs.values(); +} + +bool DirtyStateManager::hasDirtyTabs() const { + return !m_dirtyTabs.isEmpty(); +} + +void DirtyStateManager::setFilePath(QWidget* tab, const QString& path) { + if (tab) { + m_filePaths[tab] = path; + } +} + +QString DirtyStateManager::filePath(QWidget* tab) const { + return m_filePaths.value(tab); +} + +void DirtyStateManager::removeTab(QWidget* tab) { + if (!tab) return; + + m_dirtyTabs.remove(tab); + m_filePaths.remove(tab); +} diff --git a/app/dirtystatemanager.h b/app/dirtystatemanager.h new file mode 100644 index 0000000..3213639 --- /dev/null +++ b/app/dirtystatemanager.h @@ -0,0 +1,92 @@ +#ifndef DIRTYSTATEMANAGER_H +#define DIRTYSTATEMANAGER_H + +#include +#include +#include + +class QWidget; + +/** + * @brief Tracks dirty (unsaved changes) state for editor tabs. + * + * The DirtyStateManager is a singleton that tracks which editor widgets + * have unsaved changes. It emits signals when dirty state changes, allowing + * the UI to update tab titles (add/remove asterisk) and prompt on close. + */ +class DirtyStateManager : public QObject { + Q_OBJECT + +public: + static DirtyStateManager& instance(); + + /** + * @brief Mark a tab as having unsaved changes. + * @param tab The widget to mark as dirty + */ + void markDirty(QWidget* tab); + + /** + * @brief Mark a tab as having no unsaved changes. + * @param tab The widget to mark as clean + */ + void markClean(QWidget* tab); + + /** + * @brief Check if a tab has unsaved changes. + * @param tab The widget to check + * @return true if the tab has unsaved changes + */ + bool isDirty(QWidget* tab) const; + + /** + * @brief Get list of all tabs with unsaved changes. + * @return List of dirty widgets + */ + QList dirtyTabs() const; + + /** + * @brief Check if any tabs have unsaved changes. + * @return true if at least one tab is dirty + */ + bool hasDirtyTabs() const; + + /** + * @brief Store the original file path for a tab (for Save functionality). + * @param tab The widget + * @param path The file path + */ + void setFilePath(QWidget* tab, const QString& path); + + /** + * @brief Get the original file path for a tab. + * @param tab The widget + * @return The file path, or empty string if not set + */ + QString filePath(QWidget* tab) const; + + /** + * @brief Remove tracking for a tab (call when tab is closed). + * @param tab The widget being closed + */ + void removeTab(QWidget* tab); + +signals: + /** + * @brief Emitted when a tab's dirty state changes. + * @param tab The affected widget + * @param isDirty The new dirty state + */ + void dirtyStateChanged(QWidget* tab, bool isDirty); + +private: + DirtyStateManager() = default; + ~DirtyStateManager() = default; + DirtyStateManager(const DirtyStateManager&) = delete; + DirtyStateManager& operator=(const DirtyStateManager&) = delete; + + QSet m_dirtyTabs; + QMap m_filePaths; +}; + +#endif // DIRTYSTATEMANAGER_H diff --git a/app/fieldeditcommand.cpp b/app/fieldeditcommand.cpp new file mode 100644 index 0000000..b38b6ab --- /dev/null +++ b/app/fieldeditcommand.cpp @@ -0,0 +1,67 @@ +#include "fieldeditcommand.h" + +// Command ID for merging consecutive edits to the same field +static const int FIELD_EDIT_COMMAND_ID = 1001; + +FieldEditCommand::FieldEditCommand(int journalId, + const QString& fieldName, + const QVariant& oldValue, + const QVariant& newValue, + ApplyCallback applyCallback, + QUndoCommand* parent) + : QUndoCommand(parent) + , m_journalId(journalId) + , m_fieldName(fieldName) + , m_oldValue(oldValue) + , m_newValue(newValue) + , m_applyCallback(applyCallback) +{ + setText(QString("Edit %1").arg(fieldName)); +} + +void FieldEditCommand::undo() +{ + if (m_applyCallback) { + m_applyCallback(m_fieldName, m_oldValue); + } +} + +void FieldEditCommand::redo() +{ + // Skip the first redo because the value was already applied + // when the user made the edit (before command was pushed) + if (m_firstRedo) { + m_firstRedo = false; + return; + } + + if (m_applyCallback) { + m_applyCallback(m_fieldName, m_newValue); + } +} + +int FieldEditCommand::id() const +{ + return FIELD_EDIT_COMMAND_ID; +} + +bool FieldEditCommand::mergeWith(const QUndoCommand* other) +{ + // Merge consecutive edits to the same field in the same journal + // This prevents creating a new undo entry for every keystroke + if (other->id() != id()) { + return false; + } + + const FieldEditCommand* otherEdit = static_cast(other); + + // Only merge if same journal and field + if (otherEdit->m_journalId != m_journalId || + otherEdit->m_fieldName != m_fieldName) { + return false; + } + + // Keep original old value, take new value from other command + m_newValue = otherEdit->m_newValue; + return true; +} diff --git a/app/fieldeditcommand.h b/app/fieldeditcommand.h new file mode 100644 index 0000000..c285bc2 --- /dev/null +++ b/app/fieldeditcommand.h @@ -0,0 +1,45 @@ +#ifndef FIELDEDITCOMMAND_H +#define FIELDEDITCOMMAND_H + +#include +#include +#include +#include + +/** + * @brief QUndoCommand for field value edits in ScriptTypeEditorWidget + * + * Stores old and new values for a field, allowing undo/redo of edits. + * Uses callbacks to apply values since the actual application logic + * is in the editor widget. + */ +class FieldEditCommand : public QUndoCommand +{ +public: + using ApplyCallback = std::function; + + FieldEditCommand(int journalId, + const QString& fieldName, + const QVariant& oldValue, + const QVariant& newValue, + ApplyCallback applyCallback, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + int id() const override; + bool mergeWith(const QUndoCommand* other) override; + + QString fieldName() const { return m_fieldName; } + int journalId() const { return m_journalId; } + +private: + int m_journalId; + QString m_fieldName; + QVariant m_oldValue; + QVariant m_newValue; + ApplyCallback m_applyCallback; + bool m_firstRedo = true; // Skip first redo since value is already applied +}; + +#endif // FIELDEDITCOMMAND_H diff --git a/libs/dsl/scripttypeeditorwidget.cpp b/libs/dsl/scripttypeeditorwidget.cpp index 48f7643..4c2baf9 100644 --- a/libs/dsl/scripttypeeditorwidget.cpp +++ b/libs/dsl/scripttypeeditorwidget.cpp @@ -53,6 +53,12 @@ ScriptTypeEditorWidget::ScriptTypeEditorWidget(const QString& typeName, , m_typeName(typeName) , m_schema(schema) { + // Initialize debounce timer for recompilation + m_recompileDebounce = new QTimer(this); + m_recompileDebounce->setSingleShot(true); + m_recompileDebounce->setInterval(300); // 300ms debounce + connect(m_recompileDebounce, &QTimer::timeout, this, &ScriptTypeEditorWidget::emitRequestRecompile); + // Check if schema has a table field for (auto it = m_schema.begin(); it != m_schema.end(); ++it) { if (it.value().isTable) { @@ -62,11 +68,13 @@ ScriptTypeEditorWidget::ScriptTypeEditorWidget(const QString& typeName, } } + // Table layout can be built immediately (doesn't depend on values) + // Form layout is deferred to setValues() so we only show fields with data if (m_hasTableLayout) { buildTableLayout(m_tableField); - } else { - buildFormLayout(); + m_layoutBuilt = true; } + // Form layout will be built in setValues() when we know which fields have data } void ScriptTypeEditorWidget::buildTableLayout(const UiField& tableField) @@ -104,7 +112,7 @@ void ScriptTypeEditorWidget::buildTableLayout(const UiField& tableField) root->addWidget(m_splitter); } -void ScriptTypeEditorWidget::buildFormLayout() +void ScriptTypeEditorWidget::buildFormLayout(const QVariantMap& vars) { auto* root = new QVBoxLayout(this); root->setContentsMargins(0,0,0,0); @@ -119,14 +127,22 @@ void ScriptTypeEditorWidget::buildFormLayout() m_form->setHorizontalSpacing(10); m_form->setVerticalSpacing(6); - // Build rows + // Build rows - only for fields that have values in vars + // This filters out fields from unexecuted code paths (e.g., different version branches) for (auto it = m_schema.begin(); it != m_schema.end(); ++it) { const UiField& f = it.value(); + + // Skip fields not present in vars (from unexecuted branches) + if (!vars.contains(f.name)) continue; + QWidget* editor = makeEditor(f); if (f.readOnly && !f.isTable) { editor->setEnabled(false); editor->setToolTip("Read-only"); + } else if (!f.isTable) { + // Connect signals for editable fields + connectEditorSignals(editor, f.name); } auto* label = new QLabel(f.displayName.isEmpty() ? f.name : f.displayName, m_inner); @@ -191,11 +207,18 @@ QWidget* ScriptTypeEditorWidget::makeEditor(const UiField& f) { // Generic fallback auto* le = new QLineEdit(m_inner); - le->setPlaceholderText("value"); + // Don't set placeholder - empty fields should show as empty, not "value" return le; } void ScriptTypeEditorWidget::setValues(const QVariantMap& vars) { + // Build form layout lazily on first setValues() call + // This ensures we only create editors for fields that actually have values + if (!m_layoutBuilt && !m_hasTableLayout) { + buildFormLayout(vars); + m_layoutBuilt = true; + } + if (m_hasTableLayout) { // Table layout mode: populate main table and metadata tree @@ -320,8 +343,12 @@ void ScriptTypeEditorWidget::setValues(const QVariantMap& vars) { } // non-table normal controls - if (vars.contains(name)) - setEditorValue(w, vars.value(name)); + if (vars.contains(name)) { + QVariant value = vars.value(name); + setEditorValue(w, value); + // Initialize previous value for undo tracking + m_previousValues[name] = value; + } } } @@ -354,3 +381,79 @@ QVariant ScriptTypeEditorWidget::editorValue(QWidget* w) const { if (auto* cb = qobject_cast(w)) return cb->isChecked(); return {}; } + +// Write-back support methods + +void ScriptTypeEditorWidget::setJournalId(int journalId) { + m_journalId = journalId; +} + +void ScriptTypeEditorWidget::setOriginalBytes(const QByteArray& bytes) { + m_originalBytes = bytes; +} + +void ScriptTypeEditorWidget::setModified(bool modified) { + if (m_modified != modified) { + m_modified = modified; + emit modifiedChanged(modified); + } +} + +void ScriptTypeEditorWidget::connectEditorSignals(QWidget* editor, const QString& fieldName) { + // Store field name in the editor so we can retrieve it in the slot + editor->setProperty("fieldName", fieldName); + + if (auto* sb = qobject_cast(editor)) { + connect(sb, QOverload::of(&QSpinBox::valueChanged), + this, &ScriptTypeEditorWidget::onEditorValueChanged); + } + else if (auto* le = qobject_cast(editor)) { + connect(le, &QLineEdit::textChanged, + this, &ScriptTypeEditorWidget::onEditorValueChanged); + } + else if (auto* cb = qobject_cast(editor)) { + connect(cb, &QCheckBox::toggled, + this, &ScriptTypeEditorWidget::onEditorValueChanged); + } +} + +void ScriptTypeEditorWidget::onEditorValueChanged() { + QWidget* editor = qobject_cast(sender()); + if (!editor) return; + + QString fieldName = editor->property("fieldName").toString(); + if (fieldName.isEmpty()) return; + + QVariant newValue = editorValue(editor); + QVariant oldValue = m_previousValues.value(fieldName); + + // Update previous value for next change + m_previousValues[fieldName] = newValue; + + // Mark as modified + setModified(true); + + // Emit value change signal with old and new values for undo support + emit valueChanged(fieldName, oldValue, newValue); + + // Start/restart debounce timer for recompilation + m_recompileDebounce->start(); +} + +void ScriptTypeEditorWidget::setFieldValue(const QString& fieldName, const QVariant& value) { + if (!m_editors.contains(fieldName)) return; + + QWidget* editor = m_editors.value(fieldName); + setEditorValue(editor, value); + + // Update previous value tracking + m_previousValues[fieldName] = value; + + // Mark as modified and trigger recompile + setModified(true); + m_recompileDebounce->start(); +} + +void ScriptTypeEditorWidget::emitRequestRecompile() { + emit requestRecompile(); +} diff --git a/libs/dsl/scripttypeeditorwidget.h b/libs/dsl/scripttypeeditorwidget.h index d796d34..506bc57 100644 --- a/libs/dsl/scripttypeeditorwidget.h +++ b/libs/dsl/scripttypeeditorwidget.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "dsluischema.h" @@ -26,12 +27,41 @@ public: // Optional: you can pull edited values out later. QVariantMap values() const; + // Journal/write-back support + void setJournalId(int journalId); + int journalId() const { return m_journalId; } + + void setOriginalBytes(const QByteArray& bytes); + const QByteArray& originalBytes() const { return m_originalBytes; } + + // Check if values have been modified from original + bool isModified() const { return m_modified; } + void setModified(bool modified); + + // Set a specific field value (used by undo/redo) + void setFieldValue(const QString& fieldName, const QVariant& value); + +signals: + // Emitted when a field value is changed by the user (includes old value for undo) + void valueChanged(const QString& fieldName, const QVariant& oldValue, const QVariant& newValue); + + // Emitted (debounced) when values change and recompilation should happen + void requestRecompile(); + + // Emitted when modified state changes + void modifiedChanged(bool modified); + +private slots: + void onEditorValueChanged(); + void emitRequestRecompile(); + private: QWidget* makeEditor(const UiField& f); void setEditorValue(QWidget* w, const QVariant& v) const; QVariant editorValue(QWidget* w) const; void buildTableLayout(const UiField& tableField); - void buildFormLayout(); + void buildFormLayout(const QVariantMap& vars); + void connectEditorSignals(QWidget* editor, const QString& fieldName); private: QString m_typeName; @@ -43,12 +73,24 @@ private: QWidget* m_inner = nullptr; QFormLayout* m_form = nullptr; + // Layout state + bool m_layoutBuilt = false; + // Table layout mode (splitter with table + metadata tree) bool m_hasTableLayout = false; QSplitter* m_splitter = nullptr; QTableWidget* m_mainTable = nullptr; QTreeWidget* m_metaTree = nullptr; UiField m_tableField; + + // Write-back support + int m_journalId = -1; + QByteArray m_originalBytes; + bool m_modified = false; + QTimer* m_recompileDebounce = nullptr; + + // Track previous values for undo support + QMap m_previousValues; }; #endif // SCRIPTTYPEEDITORWIDGET_H