diff --git a/src/controller.cpp b/src/controller.cpp index 0df119b..44be30e 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -569,6 +569,11 @@ void RcxController::batchChangeKind(const QVector& nodeIndices, NodeKind ne } idSet = m_doc->tree.normalizePreferDescendants(idSet); if (idSet.isEmpty()) return; + + // Clear selection before batch change + m_selIds.clear(); + m_anchorLine = -1; + m_doc->undoStack.beginMacro(QString("Change type of %1 nodes").arg(idSet.size())); for (uint64_t id : idSet) { int idx = m_doc->tree.indexOfId(id); diff --git a/src/core.h b/src/core.h index 8a9338c..2e34a19 100644 --- a/src/core.h +++ b/src/core.h @@ -473,8 +473,8 @@ enum class EditTarget { Name, Type, Value }; // Column layout constants (shared with format.cpp span computation) inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line inline constexpr int kColType = 10; -inline constexpr int kColName = 24; -inline constexpr int kColValue = 22; +inline constexpr int kColName = 22; +inline constexpr int kColValue = 8; inline constexpr int kColComment = 28; // "// Enter=Save Esc=Cancel" fits inline constexpr int kSepWidth = 2; diff --git a/src/editor.cpp b/src/editor.cpp index ca263e4..e3f8fe8 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1,4 +1,5 @@ #include "editor.h" +#include #include #include #include @@ -83,7 +84,7 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) { connect(m_sci, &QsciScintilla::textChanged, this, [this]() { if (!m_editState.active) return; if (m_editState.target == EditTarget::Value) - validateEditLive(); + QTimer::singleShot(0, this, &RcxEditor::validateEditLive); }); } @@ -933,6 +934,15 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { lm->nodeKind == NodeKind::Vec4) && lm->subLine > 0) m_editState.editKind = NodeKind::Float; + // Store fixed comment column position for value editing + if (target == EditTarget::Value) { + ColumnSpan cs = commentSpanFor(*lm, lineText.size()); + m_editState.commentCol = cs.valid ? cs.start : -1; + m_editState.lastValidationOk = true; // original value is always valid + } else { + m_editState.commentCol = -1; + } + // Disable Scintilla undo during inline edit m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, (long)0); m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1); @@ -958,7 +968,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { // Show initial edit hint in comment column if (target == EditTarget::Value) - setEditComment(QStringLiteral("// Enter=Save Esc=Cancel")); + setEditComment(QStringLiteral("Enter=Save Esc=Cancel")); if (target == EditTarget::Type) QTimer::singleShot(0, this, &RcxEditor::showTypeAutocomplete); @@ -1040,6 +1050,15 @@ void RcxEditor::updateEditableIndicators(int line) { if (m_editState.active) return; if (line == m_hintLine) return; + // No cursor hints when selection is empty (prevents desync during batch ops) + if (m_currentSelIds.isEmpty()) { + if (m_hintLine >= 0) { + clearIndicatorLine(IND_EDITABLE, m_hintLine); + m_hintLine = -1; + } + return; + } + // If new line is selected, its indicators are managed by applySelectionOverlay // But we still need to clear the old non-selected hint line const LineMeta* newLm = metaForLine(line); @@ -1105,25 +1124,34 @@ void RcxEditor::applyHoverCursor() { // ── Live value validation ── void RcxEditor::setEditComment(const QString& comment) { - const LineMeta* lm = metaForLine(m_editState.line); - if (!lm) return; + // Value edit must be active + if (m_editState.commentCol < 0) return; + // Prevent re-entrancy from textChanged signal + static bool s_updating = false; + if (s_updating) return; + s_updating = true; + + // Comment is always at end of line - calculate dynamically as value length changes QString lineText = getLineText(m_sci, m_editState.line); - ColumnSpan cs = commentSpanFor(*lm, lineText.size()); - if (!cs.valid) return; + int startCol = lineText.size() - kColComment; + if (startCol < 0) { s_updating = false; return; } - // Pad/truncate comment to fixed width QString padded = comment.leftJustified(kColComment, ' ').left(kColComment); - long posA = posFromCol(m_sci, m_editState.line, cs.start); - long posB = posFromCol(m_sci, m_editState.line, cs.start + kColComment); - if (posB <= posA) return; + // Use direct position calculation from line start + long lineStart = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, + (unsigned long)m_editState.line); + long posA = lineStart + startCol; + long posB = lineStart + startCol + kColComment; QByteArray utf8 = padded.toUtf8(); m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, posA); m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, posB); m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET, (uintptr_t)utf8.size(), utf8.constData()); + + s_updating = false; } void RcxEditor::validateEditLive() { @@ -1134,13 +1162,24 @@ void RcxEditor::validateEditLive() { ? lineText.mid(m_editState.spanStart, editedLen).trimmed() : QString(); QString errorMsg = fmt::validateValue(m_editState.editKind, text); - // Show/hide error marker (red background) and update comment - if (errorMsg.isEmpty()) { + const LineMeta* lm = metaForLine(m_editState.line); + const bool isSelected = lm && m_currentSelIds.contains(lm->nodeId); + const bool isValid = errorMsg.isEmpty(); + + // Only update comment when validation state changes (avoid lag) + const bool stateChanged = (isValid != m_editState.lastValidationOk); + m_editState.lastValidationOk = isValid; + + // Show/hide error marker (red background) + // M_SELECTED has higher priority than M_ERR, so temporarily remove it when error + if (isValid) { m_sci->markerDelete(m_editState.line, M_ERR); - setEditComment(QStringLiteral("// Enter=Save Esc=Cancel")); + if (isSelected) m_sci->markerAdd(m_editState.line, M_SELECTED); + if (stateChanged) setEditComment("Enter=Save Esc=Cancel"); } else { + if (isSelected) m_sci->markerDelete(m_editState.line, M_SELECTED); m_sci->markerAdd(m_editState.line, M_ERR); - setEditComment(QStringLiteral("// ") + errorMsg); + if (stateChanged) setEditComment("! " + text); } } diff --git a/src/editor.h b/src/editor.h index 2843293..f1ea15a 100644 --- a/src/editor.h +++ b/src/editor.h @@ -88,6 +88,8 @@ private: int linelenAfterReplace = 0; QString original; NodeKind editKind = NodeKind::Int32; + int commentCol = -1; // fixed comment column (stored at edit start) + bool lastValidationOk = true; // track state to avoid redundant updates }; InlineEditState m_editState; diff --git a/src/format.cpp b/src/format.cpp index 7d75d48..e34cc8c 100644 --- a/src/format.cpp +++ b/src/format.cpp @@ -8,7 +8,7 @@ namespace rcx::fmt { // COL_TYPE and COL_NAME use shared constants from core.h (kColType, kColName) static constexpr int COL_TYPE = kColType; static constexpr int COL_NAME = kColName; -static constexpr int COL_VALUE = 22; +static constexpr int COL_VALUE = kColValue; static constexpr int COL_COMMENT = 28; // "// Enter=Save Esc=Cancel" fits static const QString SEP = QStringLiteral(" "); @@ -36,22 +36,22 @@ QString typeName(NodeKind kind) { // ── Value formatting ── -static QString hexStr(uint64_t v, int digits) { - return QStringLiteral("0x") + QString::number(v, 16).toUpper().rightJustified(digits, '0'); +static QString hexVal(uint64_t v) { + return QStringLiteral("0x") + QString::number(v, 16); } static QString rawHex(uint64_t v, int digits) { - return QString::number(v, 16).toUpper().rightJustified(digits, '0'); + return QString::number(v, 16).rightJustified(digits, '0'); } -QString fmtInt8(int8_t v) { return QString::number(v); } -QString fmtInt16(int16_t v) { return QString::number(v); } -QString fmtInt32(int32_t v) { return QString::number(v); } -QString fmtInt64(int64_t v) { return QString::number(v); } -QString fmtUInt8(uint8_t v) { return hexStr(v, 2); } -QString fmtUInt16(uint16_t v) { return hexStr(v, 4); } -QString fmtUInt32(uint32_t v) { return hexStr(v, 8); } -QString fmtUInt64(uint64_t v) { return hexStr(v, 16); } +QString fmtInt8(int8_t v) { return hexVal((uint8_t)v); } +QString fmtInt16(int16_t v) { return hexVal((uint16_t)v); } +QString fmtInt32(int32_t v) { return hexVal((uint32_t)v); } +QString fmtInt64(int64_t v) { return hexVal((uint64_t)v); } +QString fmtUInt8(uint8_t v) { return hexVal(v); } +QString fmtUInt16(uint16_t v) { return hexVal(v); } +QString fmtUInt32(uint32_t v) { return hexVal(v); } +QString fmtUInt64(uint64_t v) { return hexVal(v); } QString fmtFloat(float v) { return QString::number(v, 'f', 3); } QString fmtDouble(double v) { return QString::number(v, 'f', 6); } @@ -59,12 +59,12 @@ QString fmtBool(uint8_t v) { return v ? QStringLiteral("true") : QStringLiter QString fmtPointer32(uint32_t v) { if (v == 0) return QStringLiteral("-> NULL"); - return QStringLiteral("-> ") + hexStr(v, 8); + return QStringLiteral("-> ") + hexVal(v); } QString fmtPointer64(uint64_t v) { if (v == 0) return QStringLiteral("-> NULL"); - return QStringLiteral("-> ") + hexStr(v, 16); + return QStringLiteral("-> ") + hexVal(v); } // ── Indentation ── @@ -137,10 +137,10 @@ static QString readValueImpl(const Node& node, const Provider& prov, uint64_t addr, int subLine, ValueMode mode) { const bool display = (mode == ValueMode::Display); switch (node.kind) { - case NodeKind::Hex8: return display ? hexStr(prov.readU8(addr), 2) : rawHex(prov.readU8(addr), 2); - case NodeKind::Hex16: return display ? hexStr(prov.readU16(addr), 4) : rawHex(prov.readU16(addr), 4); - case NodeKind::Hex32: return display ? hexStr(prov.readU32(addr), 8) : rawHex(prov.readU32(addr), 8); - case NodeKind::Hex64: return display ? hexStr(prov.readU64(addr), 16): rawHex(prov.readU64(addr), 16); + case NodeKind::Hex8: return display ? hexVal(prov.readU8(addr)) : rawHex(prov.readU8(addr), 2); + case NodeKind::Hex16: return display ? hexVal(prov.readU16(addr)) : rawHex(prov.readU16(addr), 4); + case NodeKind::Hex32: return display ? hexVal(prov.readU32(addr)) : rawHex(prov.readU32(addr), 8); + case NodeKind::Hex64: return display ? hexVal(prov.readU64(addr)) : rawHex(prov.readU64(addr), 16); case NodeKind::Int8: return fmtInt8((int8_t)prov.readU8(addr)); case NodeKind::Int16: return fmtInt16((int16_t)prov.readU16(addr)); case NodeKind::Int32: return fmtInt32((int32_t)prov.readU32(addr)); @@ -175,7 +175,7 @@ static QString readValueImpl(const Node& node, const Provider& prov, line += QStringLiteral("]"); return line; } - case NodeKind::Padding: return display ? hexStr(prov.readU8(addr), 2) : rawHex(prov.readU8(addr), 2); + case NodeKind::Padding: return display ? hexVal(prov.readU8(addr)) : rawHex(prov.readU8(addr), 2); case NodeKind::UTF8: { QByteArray bytes = prov.readBytes(addr, node.strLen); int end = bytes.indexOf('\0'); @@ -401,6 +401,34 @@ QString validateValue(NodeKind kind, const QString& text) { QString s = text.trimmed(); if (s.isEmpty()) return {}; + // For integer/hex types, validate character set first + bool isHexKind = (kind >= NodeKind::Hex8 && kind <= NodeKind::Hex64) + || kind == NodeKind::Pointer32 || kind == NodeKind::Pointer64; + bool isIntKind = (kind >= NodeKind::Int8 && kind <= NodeKind::UInt64); + + if (isHexKind || isIntKind) { + bool hasHexPrefix = s.startsWith("0x", Qt::CaseInsensitive); + QString digits = hasHexPrefix ? s.mid(2) : s; + + if (hasHexPrefix || isHexKind) { + // Hex mode: only 0-9, a-f, A-F + for (QChar c : digits) { + if (!c.isDigit() && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F')) + return QStringLiteral("invalid hex '%1'").arg(c); + } + } else { + // Decimal mode: only digits (and leading minus for signed) + int start = 0; + bool isSigned = (kind >= NodeKind::Int8 && kind <= NodeKind::Int64); + if (isSigned && !digits.isEmpty() && digits[0] == '-') start = 1; + for (int i = start; i < digits.size(); i++) { + if (!digits[i].isDigit()) + return QStringLiteral("invalid '%1'").arg(digits[i]); + } + } + } + + // Then do the actual parse for range checking bool ok; parseValue(kind, text, &ok); if (ok) return {}; @@ -409,7 +437,7 @@ QString validateValue(NodeKind kind, const QString& text) { const auto* m = kindMeta(kind); if (m && m->size > 0 && m->size <= 8) { uint64_t maxVal = (m->size == 8) ? ~0ULL : ((1ULL << (m->size * 8)) - 1); - return QStringLiteral("0x%1 max").arg(maxVal, m->size * 2, 16, QChar('0')); + return QStringLiteral("max 0x%1").arg(maxVal, m->size * 2, 16, QChar('0')); } return QStringLiteral("invalid"); } diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp index e1295ba..b35823b 100644 --- a/tests/test_editor.cpp +++ b/tests/test_editor.cpp @@ -437,6 +437,93 @@ private slots: QVERIFY(lm); QVERIFY(sel.contains(lm->nodeIdx)); } + + // ── Test: value edit echoes to comment column ── + void testValueEditCommentEcho() { + m_editor->applyDocument(m_result); + + // Begin value edit on line 1 (UInt16 field) + bool ok = m_editor->beginInlineEdit(EditTarget::Value, 1); + QVERIFY(ok); + QVERIFY(m_editor->isEditing()); + + // Get the line text before any typing + QString lineBefore; + int len = (int)m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_LINELENGTH, (unsigned long)1); + if (len > 0) { + QByteArray buf(len + 1, '\0'); + m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_GETLINE, (unsigned long)1, (void*)buf.data()); + lineBefore = QString::fromUtf8(buf.constData(), len).trimmed(); + } + + // Initial comment should contain "Enter=Save Esc=Cancel" + QVERIFY2(lineBefore.contains("Enter=Save"), + qPrintable("Initial comment missing, got: " + lineBefore)); + + // Type a digit to trigger validateEditLive + QKeyEvent key5(QEvent::KeyPress, Qt::Key_5, Qt::NoModifier, "5"); + QApplication::sendEvent(m_editor->scintilla(), &key5); + QApplication::processEvents(); + + // Get line text after typing + QString lineAfter; + len = (int)m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_LINELENGTH, (unsigned long)1); + if (len > 0) { + QByteArray buf(len + 1, '\0'); + m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_GETLINE, (unsigned long)1, (void*)buf.data()); + lineAfter = QString::fromUtf8(buf.constData(), len).trimmed(); + } + + // Comment should show "!" prefix for invalid value + // Since "0x5a4d" + "5" = "0x5a4d5" = 370509 > 65535, it's invalid for UInt16 + QVERIFY2(lineAfter.contains("! "), + qPrintable("Comment should show '!' for invalid value, got: " + lineAfter)); + + // Cancel and reset + m_editor->cancelInlineEdit(); + m_editor->applyDocument(m_result); + } + + // ── Test: value validation shows error indicator ── + void testValueValidationError() { + m_editor->applyDocument(m_result); + + // Begin value edit on line 1 (UInt16 field, value = 23117) + bool ok = m_editor->beginInlineEdit(EditTarget::Value, 1); + QVERIFY(ok); + + // Type "999" to make value invalid for UInt16 (appends to existing, making it too large) + // Original value 23117 -> typing "999" at end makes it invalid (23117999 > 65535) + const char* digits = "999"; + for (int i = 0; digits[i]; i++) { + QKeyEvent key(QEvent::KeyPress, Qt::Key_9, Qt::NoModifier, QString(digits[i])); + QApplication::sendEvent(m_editor->scintilla(), &key); + QApplication::processEvents(); + } + + // Get line text - comment should show "! " prefix (error) + QString lineText; + int len = (int)m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_LINELENGTH, (unsigned long)1); + if (len > 0) { + QByteArray buf(len + 1, '\0'); + m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_GETLINE, (unsigned long)1, (void*)buf.data()); + lineText = QString::fromUtf8(buf.constData(), len).trimmed(); + } + + // Comment should show "! " prefix for invalid value + QVERIFY2(lineText.contains("! "), + qPrintable("Comment should show '! ' for invalid value, got: " + lineText)); + + // Cancel and reset + m_editor->cancelInlineEdit(); + m_editor->applyDocument(m_result); + } }; QTEST_MAIN(TestEditor)