From 06c3251f7491c298e5863c0a01135e3ae1610a1e Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 3 Feb 2026 14:33:40 -0700 Subject: [PATCH] Inline edit UX improvements: selection clamping, auto-select, validation fixes - Constrain selection/cursor to edit span boundaries during inline edit - Auto-select entire text when entering edit mode (Name, Value, Type) - Double-click during edit selects entire editable text - Fix vector component validation (subLine >= 0 for x component) - Accept EU decimal separator (comma) for float parsing - Darker selection highlight (35,35,35) vs hover (43,43,43) - Remove blue text indicator, use hidden style - Fix validation error message display Co-Authored-By: Claude Opus 4.5 --- src/core.h | 2 +- src/editor.cpp | 71 ++++++++++++++++++++++++++++++++++--------- src/editor.h | 1 + src/format.cpp | 15 ++++++--- src/main.cpp | 7 ++--- tests/test_editor.cpp | 33 +++++++++++++++----- 6 files changed, 98 insertions(+), 31 deletions(-) diff --git a/src/core.h b/src/core.h index 2e34a19..9187c92 100644 --- a/src/core.h +++ b/src/core.h @@ -474,7 +474,7 @@ enum class EditTarget { Name, Type, Value }; inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line inline constexpr int kColType = 10; inline constexpr int kColName = 22; -inline constexpr int kColValue = 8; +inline constexpr int kColValue = 32; 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 e3f8fe8..6e803ad 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -86,6 +86,9 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) { if (m_editState.target == EditTarget::Value) QTimer::singleShot(0, this, &RcxEditor::validateEditLive); }); + + connect(m_sci, &QsciScintilla::selectionChanged, + this, &RcxEditor::clampEditSelection); } void RcxEditor::setupScintilla() { @@ -116,11 +119,9 @@ void RcxEditor::setupScintilla() { m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELFORE, (long)0, (long)0); m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELBACK, (long)0, (long)0); - // Editable-field link-style indicator (colored text) + // Editable-field indicator - set to HIDDEN (no visual, avoids INDIC_PLAIN underline) m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, - IND_EDITABLE, 17 /*INDIC_TEXTFORE*/); - m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, - IND_EDITABLE, QColor("#569cd6")); + IND_EDITABLE, 5 /*INDIC_HIDDEN*/); // Hex/Padding node dim indicator — overrides text color to gray m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, @@ -232,7 +233,7 @@ void RcxEditor::setupMarkers() { // M_SELECTED (7): full-row selection highlight (higher = wins over hover) m_sci->markerDefine(QsciScintilla::Background, M_SELECTED); - m_sci->setMarkerBackgroundColor(QColor(53, 53, 53), M_SELECTED); + m_sci->setMarkerBackgroundColor(QColor(35, 35, 35), M_SELECTED); } void RcxEditor::allocateMarginStyles() { @@ -753,9 +754,11 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { m_pendingClickNodeId = 0; } } - // Block double/triple-click during edit mode (prevents word/line selection) + // Double-click during edit mode: select entire editable text if (obj == m_sci->viewport() && m_editState.active && event->type() == QEvent::MouseButtonDblClick) { + m_sci->setSelection(m_editState.line, m_editState.spanStart, + m_editState.line, editEndCol()); return true; } if (obj == m_sci->viewport() && !m_editState.active @@ -931,7 +934,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { m_editState.linelenAfterReplace = lineText.size(); m_editState.editKind = lm->nodeKind; if ((lm->nodeKind == NodeKind::Vec2 || lm->nodeKind == NodeKind::Vec3 || - lm->nodeKind == NodeKind::Vec4) && lm->subLine > 0) + lm->nodeKind == NodeKind::Vec4) && lm->subLine >= 0) m_editState.editKind = NodeKind::Float; // Store fixed comment column position for value editing @@ -964,7 +967,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { (unsigned long)line); long posStart = lineStart + m_editState.spanStart; long posEnd = posStart + trimmed.toUtf8().size(); - m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL, posEnd, posEnd); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL, posStart, posEnd); // Show initial edit hint in comment column if (target == EditTarget::Value) @@ -982,6 +985,50 @@ int RcxEditor::editEndCol() const { return m_editState.spanStart + m_editState.original.size() + delta; } +void RcxEditor::clampEditSelection() { + if (!m_editState.active) return; + + static bool s_clamping = false; + if (s_clamping) return; + s_clamping = true; + + int selStartLine, selStartCol, selEndLine, selEndCol; + m_sci->getSelection(&selStartLine, &selStartCol, &selEndLine, &selEndCol); + + int editEnd = editEndCol(); + bool isCursor = (selStartLine == selEndLine && selStartCol == selEndCol); + + if (isCursor) { + // Cursor positioning (no selection) - only clamp if outside bounds + if (selStartLine != m_editState.line || + selStartCol < m_editState.spanStart || selStartCol > editEnd) { + int clampedCol = qBound(m_editState.spanStart, selStartCol, editEnd); + m_sci->setCursorPosition(m_editState.line, clampedCol); + } + } else { + // Actual selection - clamp both ends to edit span + bool clamped = false; + + // Force to edit line + if (selStartLine != m_editState.line || selEndLine != m_editState.line) { + m_sci->setSelection(m_editState.line, m_editState.spanStart, + m_editState.line, editEnd); + s_clamping = false; + return; + } + + if (selStartCol < m_editState.spanStart) { selStartCol = m_editState.spanStart; clamped = true; } + if (selEndCol < m_editState.spanStart) { selEndCol = m_editState.spanStart; clamped = true; } + if (selStartCol > editEnd) { selStartCol = editEnd; clamped = true; } + if (selEndCol > editEnd) { selEndCol = editEnd; clamped = true; } + + if (clamped) + m_sci->setSelection(selStartLine, selStartCol, selEndLine, selEndCol); + } + + s_clamping = false; +} + // ── Commit inline edit ── void RcxEditor::commitInlineEdit() { @@ -1015,11 +1062,7 @@ void RcxEditor::showTypeAutocomplete() { if (!m_editState.active || m_editState.target != EditTarget::Type) return; - // Collapse selection to start — old type text stays visible - long lineStart = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, - (unsigned long)m_editState.line); - long posStart = lineStart + m_editState.spanStart; - m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, posStart); + // Selection stays intact - typing/autocomplete will replace selected text // Build list from typeName (matches what the editor displays) QByteArray list = allTypeNamesForUI().join(' ').toUtf8(); @@ -1179,7 +1222,7 @@ void RcxEditor::validateEditLive() { } else { if (isSelected) m_sci->markerDelete(m_editState.line, M_SELECTED); m_sci->markerAdd(m_editState.line, M_ERR); - if (stateChanged) setEditComment("! " + text); + if (stateChanged) setEditComment("! " + errorMsg); } } diff --git a/src/editor.h b/src/editor.h index f1ea15a..5d9d339 100644 --- a/src/editor.h +++ b/src/editor.h @@ -116,6 +116,7 @@ private: void applyHoverHighlight(); void validateEditLive(); void setEditComment(const QString& comment); + void clampEditSelection(); // ── Refactored helpers ── struct HitInfo { int line = -1; int col = -1; uint64_t nodeId = 0; bool inFoldCol = false; }; diff --git a/src/format.cpp b/src/format.cpp index e34cc8c..031ffe9 100644 --- a/src/format.cpp +++ b/src/format.cpp @@ -53,8 +53,8 @@ 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); } +QString fmtFloat(float v) { return QString::number(v, 'g', 4); } // 4 sig figs keeps it short +QString fmtDouble(double v) { return QString::number(v, 'g', 6); } QString fmtBool(uint8_t v) { return v ? QStringLiteral("true") : QStringLiteral("false"); } QString fmtPointer32(uint32_t v) { @@ -351,11 +351,13 @@ QByteArray parseValue(NodeKind kind, const QString& text, bool* ok) { case NodeKind::UInt32: { int b = s.startsWith("0x",Qt::CaseInsensitive)?16:10; uint val = stripHex(s).toUInt(ok,b); return *ok ? toBytes(val) : QByteArray{}; } case NodeKind::UInt64: { int b = s.startsWith("0x",Qt::CaseInsensitive)?16:10; qulonglong val = stripHex(s).toULongLong(ok,b); return *ok ? toBytes(val) : QByteArray{}; } case NodeKind::Float: { - float val = s.toFloat(ok); + QString n = s; n.replace(',', '.'); // Accept EU decimal separator + float val = n.toFloat(ok); return *ok ? toBytes(val) : QByteArray{}; } case NodeKind::Double: { - double val = s.toDouble(ok); + QString n = s; n.replace(',', '.'); // Accept EU decimal separator + double val = n.toDouble(ok); return *ok ? toBytes(val) : QByteArray{}; } case NodeKind::Bool: { @@ -433,6 +435,11 @@ QString validateValue(NodeKind kind, const QString& text) { parseValue(kind, text, &ok); if (ok) return {}; + // Type-appropriate error messages + bool isFloatKind = (kind == NodeKind::Float || kind == NodeKind::Double); + if (isFloatKind) + return QStringLiteral("invalid number"); + // Return byte-capacity max based on type size const auto* m = kindMeta(kind); if (m && m->size > 0 && m->size <= 8) { diff --git a/src/main.cpp b/src/main.cpp index b879d25..2aea8ba 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -435,12 +435,11 @@ void MainWindow::newFile() { } } - // ── 0x100 bytes of Hex64 padding (32 nodes) ── + // ── Fill with Hex64 until 0x6000 for stress testing ── int padStart = oh + 0xF0; // end of optional header - for (int i = 0; i < 32; i++) { - int off = padStart + i * 8; + for (int off = padStart; off < 0x6000; off += 8) { add(NodeKind::Hex64, - QString("pad_%1").arg(off, 4, 16, QChar('0')), + QString("data_%1").arg(off, 4, 16, QChar('0')), off); } diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp index b35823b..723473f 100644 --- a/tests/test_editor.cpp +++ b/tests/test_editor.cpp @@ -3,24 +3,29 @@ #include #include #include +#include #include #include "editor.h" #include "core.h" using namespace rcx; -// Minimal provider for testing +// Load first 0x6000 bytes of the test exe for realistic data static FileProvider makeTestProvider() { - QByteArray data(256, '\0'); - // Write known values: uint16_t=23117 at offset 0, Hex64 at offset 8 - uint16_t u16 = 23117; - memcpy(data.data(), &u16, 2); - uint64_t h64 = 0x4D5A900000000000ULL; - memcpy(data.data() + 8, &h64, 8); + QFile exe(QCoreApplication::applicationFilePath()); + if (exe.open(QIODevice::ReadOnly)) { + QByteArray data = exe.read(0x6000); + exe.close(); + if (data.size() >= 0x6000) + return FileProvider(data); + } + // Fallback: minimal PE header stub + QByteArray data(0x6000, '\0'); + data[0] = 'M'; data[1] = 'Z'; // DOS signature return FileProvider(data); } -// Build a simple tree with a struct containing a few fields +// Build a tree covering 0x6000 bytes with Hex64 fields static NodeTree makeTestTree() { NodeTree tree; tree.baseAddress = 0; @@ -33,6 +38,7 @@ static NodeTree makeTestTree() { int ri = tree.addNode(root); uint64_t rootId = tree.nodes[ri].id; + // First two fields for existing tests Node f1; f1.kind = NodeKind::UInt16; f1.name = "field_u16"; @@ -47,6 +53,17 @@ static NodeTree makeTestTree() { f2.offset = 8; tree.addNode(f2); + // Fill remaining 0x6000 bytes with Hex64 fields (8 bytes each) + // Start at offset 16 (0x10), go to 0x6000 + for (int off = 0x10; off < 0x6000; off += 8) { + Node f; + f.kind = NodeKind::Hex64; + f.name = QString("data_%1").arg(off, 4, 16, QChar('0')); + f.parentId = rootId; + f.offset = off; + tree.addNode(f); + } + return tree; }