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:
parent
908100f487
commit
05c70c1108
54
app/dirtystatemanager.cpp
Normal file
54
app/dirtystatemanager.cpp
Normal 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
92
app/dirtystatemanager.h
Normal 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
67
app/fieldeditcommand.cpp
Normal 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
45
app/fieldeditcommand.h
Normal 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
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user