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 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-02-03 14:33:40 -07:00
parent a7e67b12fe
commit 06c3251f74
6 changed files with 98 additions and 31 deletions

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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; };

View File

@@ -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<uint32_t>(val) : QByteArray{}; }
case NodeKind::UInt64: { int b = s.startsWith("0x",Qt::CaseInsensitive)?16:10; qulonglong val = stripHex(s).toULongLong(ok,b); return *ok ? toBytes<uint64_t>(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<float>(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<double>(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) {

View File

@@ -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);
}

View File

@@ -3,24 +3,29 @@
#include <QApplication>
#include <QKeyEvent>
#include <QFocusEvent>
#include <QFile>
#include <Qsci/qsciscintilla.h>
#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;
}