Add field editing UI with undo support and dirty state tracking

Implement infrastructure for editing parsed binary fields:

FieldEditCommand (QUndoCommand):
- Undo/redo support for field value changes
- Stores old and new values with field metadata
- Integrates with Qt's undo stack

DirtyStateManager:
- Tracks modified state per-tab
- Emits signals when dirty state changes
- Enables save prompts and tab indicators

ScriptTypeEditorWidget enhancements:
- Add recompilation debouncing (300ms) for responsive editing
- Build form layout only for fields with values (skip unexecuted branches)
- Support edit signals for modified fields
- Improved table and form layout building

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
njohnson 2026-01-12 20:54:23 -05:00
parent 908100f487
commit 05c70c1108
6 changed files with 411 additions and 8 deletions

54
app/dirtystatemanager.cpp Normal file
View File

@ -0,0 +1,54 @@
#include "dirtystatemanager.h"
#include <QWidget>
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<QWidget*> 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);
}

92
app/dirtystatemanager.h Normal file
View File

@ -0,0 +1,92 @@
#ifndef DIRTYSTATEMANAGER_H
#define DIRTYSTATEMANAGER_H
#include <QObject>
#include <QSet>
#include <QMap>
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<QWidget*> 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<QWidget*> m_dirtyTabs;
QMap<QWidget*, QString> m_filePaths;
};
#endif // DIRTYSTATEMANAGER_H

67
app/fieldeditcommand.cpp Normal file
View File

@ -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<const FieldEditCommand*>(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;
}

45
app/fieldeditcommand.h Normal file
View File

@ -0,0 +1,45 @@
#ifndef FIELDEDITCOMMAND_H
#define FIELDEDITCOMMAND_H
#include <QUndoCommand>
#include <QVariant>
#include <QString>
#include <functional>
/**
* @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<void(const QString& fieldName, const QVariant& value)>;
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

View File

@ -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<QCheckBox*>(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<QSpinBox*>(editor)) {
connect(sb, QOverload<int>::of(&QSpinBox::valueChanged),
this, &ScriptTypeEditorWidget::onEditorValueChanged);
}
else if (auto* le = qobject_cast<QLineEdit*>(editor)) {
connect(le, &QLineEdit::textChanged,
this, &ScriptTypeEditorWidget::onEditorValueChanged);
}
else if (auto* cb = qobject_cast<QCheckBox*>(editor)) {
connect(cb, &QCheckBox::toggled,
this, &ScriptTypeEditorWidget::onEditorValueChanged);
}
}
void ScriptTypeEditorWidget::onEditorValueChanged() {
QWidget* editor = qobject_cast<QWidget*>(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();
}

View File

@ -4,6 +4,7 @@
#include <QWidget>
#include <QMap>
#include <QVariant>
#include <QTimer>
#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<QString, QVariant> m_previousValues;
};
#endif // SCRIPTTYPEEDITORWIDGET_H