#include "controller.h" #include "providers/process_provider.h" #include "processpicker.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #endif namespace rcx { // Footer selection ID: set high bit to distinguish footer-only selections from node selections static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL; static QString elide(QString s, int max) { if (max <= 0) return {}; if (s.size() <= max) return s; if (max == 1) return QStringLiteral("\u2026"); return s.left(max - 1) + QChar(0x2026); } static QString elideLeft(const QString& s, int max) { if (s.size() <= max) return s; if (max <= 1) return QStringLiteral("\u2026").left(max); return QStringLiteral("\u2026") + s.right(max - 1); } static QString crumbFor(const rcx::NodeTree& t, uint64_t nodeId) { QStringList parts; QSet seen; uint64_t cur = nodeId; while (cur != 0 && !seen.contains(cur)) { seen.insert(cur); int idx = t.indexOfId(cur); if (idx < 0) break; const auto& n = t.nodes[idx]; parts << (n.name.isEmpty() ? QStringLiteral("") : n.name); cur = n.parentId; } std::reverse(parts.begin(), parts.end()); if (parts.size() > 4) parts = {parts.front(), QStringLiteral("\u2026"), parts[parts.size() - 2], parts.back()}; return parts.join(QStringLiteral(" \u203A ")); } // ── RcxDocument ── RcxDocument::RcxDocument(QObject* parent) : QObject(parent) , provider(std::make_unique()) { connect(&undoStack, &QUndoStack::cleanChanged, this, [this](bool clean) { modified = !clean; }); } ComposeResult RcxDocument::compose() const { return rcx::compose(tree, *provider); } bool RcxDocument::save(const QString& path) { QJsonObject json = tree.toJson(); QJsonDocument jdoc(json); QFile file(path); if (!file.open(QIODevice::WriteOnly)) return false; file.write(jdoc.toJson(QJsonDocument::Indented)); filePath = path; undoStack.setClean(); modified = false; return true; } bool RcxDocument::load(const QString& path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return false; undoStack.clear(); QJsonDocument jdoc = QJsonDocument::fromJson(file.readAll()); tree = NodeTree::fromJson(jdoc.object()); filePath = path; modified = false; emit documentChanged(); return true; } void RcxDocument::loadData(const QString& binaryPath) { QFile file(binaryPath); if (!file.open(QIODevice::ReadOnly)) return; undoStack.clear(); provider = std::make_unique( file.readAll(), QFileInfo(binaryPath).fileName()); dataPath = binaryPath; tree.baseAddress = 0; emit documentChanged(); } void RcxDocument::loadData(const QByteArray& data) { undoStack.clear(); provider = std::make_unique(data); tree.baseAddress = 0; emit documentChanged(); } // ── RcxCommand ── RcxCommand::RcxCommand(RcxController* ctrl, Command cmd) : m_ctrl(ctrl), m_cmd(cmd) {} void RcxCommand::undo() { m_ctrl->applyCommand(m_cmd, true); } void RcxCommand::redo() { m_ctrl->applyCommand(m_cmd, false); } // ── RcxController ── RcxController::RcxController(RcxDocument* doc, QWidget* parent) : QObject(parent), m_doc(doc) { connect(m_doc, &RcxDocument::documentChanged, this, &RcxController::refresh); } RcxEditor* RcxController::primaryEditor() const { return m_editors.isEmpty() ? nullptr : m_editors.first(); } RcxEditor* RcxController::addSplitEditor(QSplitter* splitter) { auto* editor = new RcxEditor(splitter); splitter->addWidget(editor); m_editors.append(editor); connectEditor(editor); if (!m_lastResult.text.isEmpty()) { editor->applyDocument(m_lastResult); } updateCommandRow(); return editor; } void RcxController::removeSplitEditor(RcxEditor* editor) { m_editors.removeOne(editor); editor->deleteLater(); } void RcxController::connectEditor(RcxEditor* editor) { connect(editor, &RcxEditor::marginClicked, this, [this, editor](int margin, int line, Qt::KeyboardModifiers mods) { handleMarginClick(editor, margin, line, mods); }); connect(editor, &RcxEditor::contextMenuRequested, this, [this, editor](int line, int nodeIdx, int subLine, QPoint globalPos) { showContextMenu(editor, line, nodeIdx, subLine, globalPos); }); connect(editor, &RcxEditor::nodeClicked, this, [this, editor](int line, uint64_t nodeId, Qt::KeyboardModifiers mods) { handleNodeClick(editor, line, nodeId, mods); }); // Inline editing signals connect(editor, &RcxEditor::inlineEditCommitted, this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text) { // CommandRow BaseAddress/Source edit has nodeIdx=-1 if (nodeIdx < 0 && target != EditTarget::BaseAddress && target != EditTarget::Source) { refresh(); return; } switch (target) { case EditTarget::Name: { if (text.isEmpty()) break; const Node& node = m_doc->tree.nodes[nodeIdx]; // ASCII edit on Hex/Padding nodes if (isHexPreview(node.kind)) { setNodeValue(nodeIdx, subLine, text, /*isAscii=*/true); } else { renameNode(nodeIdx, text); } break; } case EditTarget::Type: { // Check for array type syntax: "type[count]" e.g. "int32_t[10]" int bracketPos = text.indexOf('['); if (bracketPos > 0 && text.endsWith(']')) { QString elemTypeName = text.left(bracketPos).trimmed(); QString countStr = text.mid(bracketPos + 1, text.size() - bracketPos - 2); bool countOk; int newCount = countStr.toInt(&countOk); if (countOk && newCount > 0) { bool typeOk; NodeKind elemKind = kindFromTypeName(elemTypeName, &typeOk); if (typeOk && nodeIdx < m_doc->tree.nodes.size()) { const Node& node = m_doc->tree.nodes[nodeIdx]; if (node.kind == NodeKind::Array) { m_doc->undoStack.push(new RcxCommand(this, cmd::ChangeArrayMeta{node.id, node.elementKind, elemKind, node.arrayLen, newCount})); } } } } else { // Regular type change bool ok; NodeKind k = kindFromTypeName(text, &ok); if (ok) { changeNodeKind(nodeIdx, k); } else if (nodeIdx < m_doc->tree.nodes.size()) { // Check if it's a defined struct type name bool isStructType = false; for (const auto& n : m_doc->tree.nodes) { if (n.kind == NodeKind::Struct && n.structTypeName == text) { isStructType = true; break; } } if (isStructType) { auto& node = m_doc->tree.nodes[nodeIdx]; if (node.kind != NodeKind::Struct) changeNodeKind(nodeIdx, NodeKind::Struct); // Set the struct type name via rename of structTypeName int idx = m_doc->tree.indexOfId(node.id); if (idx >= 0) m_doc->tree.nodes[idx].structTypeName = text; } } } break; } case EditTarget::Value: setNodeValue(nodeIdx, subLine, text); break; case EditTarget::BaseAddress: { QString s = text.trimmed(); // Support simple equations: 0x10+0x4, 0x100-0x10, etc. uint64_t newBase = 0; bool ok = true; int pos = 0; bool firstTerm = true; bool adding = true; while (pos < s.size() && ok) { // Skip whitespace while (pos < s.size() && s[pos].isSpace()) pos++; if (pos >= s.size()) break; // Check for +/- operator (except first term) if (!firstTerm) { if (s[pos] == '+') { adding = true; pos++; } else if (s[pos] == '-') { adding = false; pos++; } else { ok = false; break; } while (pos < s.size() && s[pos].isSpace()) pos++; } // Parse hex number (with or without 0x prefix) int start = pos; bool hasPrefix = (pos + 1 < s.size() && s[pos] == '0' && (s[pos+1] == 'x' || s[pos+1] == 'X')); if (hasPrefix) pos += 2; int numStart = pos; while (pos < s.size() && (s[pos].isDigit() || (s[pos] >= 'a' && s[pos] <= 'f') || (s[pos] >= 'A' && s[pos] <= 'F'))) pos++; if (pos == numStart) { ok = false; break; } QString numStr = s.mid(numStart, pos - numStart); uint64_t val = numStr.toULongLong(&ok, 16); if (!ok) break; if (adding) newBase += val; else newBase -= val; firstTerm = false; } if (ok && newBase != m_doc->tree.baseAddress) { uint64_t oldBase = m_doc->tree.baseAddress; m_doc->undoStack.push(new RcxCommand(this, cmd::ChangeBase{oldBase, newBase})); } break; } case EditTarget::Source: { if (text == QStringLiteral("File")) { auto* w = qobject_cast(parent()); QString path = QFileDialog::getOpenFileName(w, "Load Binary Data", {}, "All Files (*)"); if (!path.isEmpty()) m_doc->loadData(path); } else if (text == QStringLiteral("Process")) { #ifdef _WIN32 auto* w = qobject_cast(parent()); ProcessPicker picker(w); if (picker.exec() == QDialog::Accepted) { attachToProcess(picker.selectedProcessId(), picker.selectedProcessName()); } #endif } break; } case EditTarget::ArrayIndex: case EditTarget::ArrayCount: // Array navigation removed - these cases are unreachable break; } // Always refresh to restore canonical text (handles parse failures, no-ops, etc.) refresh(); }); connect(editor, &RcxEditor::inlineEditCancelled, this, [this]() { refresh(); }); } void RcxController::refresh() { m_lastResult = m_doc->compose(); // Prune stale selections (nodes removed by undo/redo/delete) QSet valid; for (uint64_t id : m_selIds) { uint64_t nodeId = id & ~kFooterIdBit; // Strip footer bit for lookup if (m_doc->tree.indexOfId(nodeId) >= 0) valid.insert(id); // Keep original ID (with footer bit if present) } m_selIds = valid; // Collect unique struct type names for the type picker QStringList customTypes; QSet seen; for (const auto& node : m_doc->tree.nodes) { if (node.kind == NodeKind::Struct && !node.structTypeName.isEmpty()) { if (!seen.contains(node.structTypeName)) { seen.insert(node.structTypeName); customTypes << node.structTypeName; } } } for (auto* editor : m_editors) { editor->setCustomTypeNames(customTypes); ViewState vs = editor->saveViewState(); editor->applyDocument(m_lastResult); editor->restoreViewState(vs); } applySelectionOverlays(); updateCommandRow(); } void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; auto& node = m_doc->tree.nodes[nodeIdx]; int oldSize = node.byteSize(); // Compute what byteSize() would be with the new kind Node tmp = node; tmp.kind = newKind; int newSize = tmp.byteSize(); if (newSize > 0 && newSize < oldSize) { // Shrinking: insert hex padding to fill gap (no offset shift) int gap = oldSize - newSize; uint64_t parentId = node.parentId; int baseOffset = node.offset + newSize; // Push type change with no offset adjustments m_doc->undoStack.push(new RcxCommand(this, cmd::ChangeKind{node.id, node.kind, newKind, {}})); // Insert hex nodes to fill the gap (largest first for alignment) int padOffset = baseOffset; while (gap > 0) { NodeKind padKind; int padSize; if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; } else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; } else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; } else { padKind = NodeKind::Hex8; padSize = 1; } insertNode(parentId, padOffset, padKind, QString("pad_%1").arg(padOffset, 2, 16, QChar('0'))); padOffset += padSize; gap -= padSize; } } else { // Same size or larger: adjust sibling offsets as before int delta = newSize - oldSize; QVector adjs; if (delta != 0 && oldSize > 0 && newSize > 0) { int oldEnd = node.offset + oldSize; auto siblings = m_doc->tree.childrenOf(node.parentId); for (int si : siblings) { if (si == nodeIdx) continue; auto& sib = m_doc->tree.nodes[si]; if (sib.offset >= oldEnd) adjs.append({sib.id, sib.offset, sib.offset + delta}); } } m_doc->undoStack.push(new RcxCommand(this, cmd::ChangeKind{node.id, node.kind, newKind, adjs})); } } void RcxController::renameNode(int nodeIdx, const QString& newName) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; auto& node = m_doc->tree.nodes[nodeIdx]; m_doc->undoStack.push(new RcxCommand(this, cmd::Rename{node.id, node.name, newName})); } void RcxController::insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name) { Node n; n.kind = kind; n.name = name; n.parentId = parentId; if (offset < 0) { // Auto-place after last sibling with alignment int maxEnd = 0; auto siblings = m_doc->tree.childrenOf(parentId); for (int si : siblings) { auto& sn = m_doc->tree.nodes[si]; int sz = (sn.kind == NodeKind::Struct || sn.kind == NodeKind::Array) ? m_doc->tree.structSpan(sn.id) : sn.byteSize(); int end = sn.offset + sz; if (end > maxEnd) maxEnd = end; } int align = alignmentFor(kind); n.offset = (maxEnd + align - 1) / align * align; } else { n.offset = offset; } // Reserve unique ID atomically before pushing command n.id = m_doc->tree.reserveId(); m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n})); } void RcxController::removeNode(int nodeIdx) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; const Node& node = m_doc->tree.nodes[nodeIdx]; uint64_t nodeId = node.id; uint64_t parentId = node.parentId; // Compute size of deleted node/subtree int deletedSize = (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) ? m_doc->tree.structSpan(node.id) : node.byteSize(); int deletedEnd = node.offset + deletedSize; // Find siblings after this node and compute offset adjustments QVector adjs; if (parentId != 0) { // only adjust if not root-level auto siblings = m_doc->tree.childrenOf(parentId); for (int si : siblings) { if (si == nodeIdx) continue; auto& sib = m_doc->tree.nodes[si]; if (sib.offset >= deletedEnd) { adjs.append({sib.id, sib.offset, sib.offset - deletedSize}); } } } // Collect subtree QVector indices = m_doc->tree.subtreeIndices(nodeId); QVector subtree; for (int i : indices) subtree.append(m_doc->tree.nodes[i]); m_doc->undoStack.push(new RcxCommand(this, cmd::Remove{nodeId, subtree, adjs})); } void RcxController::toggleCollapse(int nodeIdx) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; auto& node = m_doc->tree.nodes[nodeIdx]; m_doc->undoStack.push(new RcxCommand(this, cmd::Collapse{node.id, node.collapsed, !node.collapsed})); } void RcxController::applyCommand(const Command& command, bool isUndo) { auto& tree = m_doc->tree; std::visit([&](auto&& c) { using T = std::decay_t; if constexpr (std::is_same_v) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) tree.nodes[idx].kind = isUndo ? c.oldKind : c.newKind; for (const auto& adj : c.offAdjs) { int ai = tree.indexOfId(adj.nodeId); if (ai >= 0) tree.nodes[ai].offset = isUndo ? adj.oldOffset : adj.newOffset; } } else if constexpr (std::is_same_v) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) tree.nodes[idx].name = isUndo ? c.oldName : c.newName; } else if constexpr (std::is_same_v) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) tree.nodes[idx].collapsed = isUndo ? c.oldState : c.newState; } else if constexpr (std::is_same_v) { if (isUndo) { int idx = tree.indexOfId(c.node.id); if (idx >= 0) { tree.nodes.remove(idx); tree.invalidateIdCache(); } } else { tree.addNode(c.node); } } else if constexpr (std::is_same_v) { if (isUndo) { // Restore nodes first for (const Node& n : c.subtree) tree.addNode(n); // Revert offset adjustments for (const auto& adj : c.offAdjs) { int ai = tree.indexOfId(adj.nodeId); if (ai >= 0) tree.nodes[ai].offset = adj.oldOffset; } } else { // Apply offset adjustments first (before removing changes indices) for (const auto& adj : c.offAdjs) { int ai = tree.indexOfId(adj.nodeId); if (ai >= 0) tree.nodes[ai].offset = adj.newOffset; } // Remove nodes QVector indices = tree.subtreeIndices(c.nodeId); std::sort(indices.begin(), indices.end(), std::greater()); for (int idx : indices) tree.nodes.remove(idx); tree.invalidateIdCache(); } } else if constexpr (std::is_same_v) { tree.baseAddress = isUndo ? c.oldBase : c.newBase; } else if constexpr (std::is_same_v) { const QByteArray& bytes = isUndo ? c.oldBytes : c.newBytes; if (!m_doc->provider->writeBytes(c.addr, bytes)) qWarning() << "WriteBytes failed at address" << Qt::hex << c.addr; } else if constexpr (std::is_same_v) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) { tree.nodes[idx].elementKind = isUndo ? c.oldElementKind : c.newElementKind; tree.nodes[idx].arrayLen = isUndo ? c.oldArrayLen : c.newArrayLen; if (tree.nodes[idx].viewIndex >= tree.nodes[idx].arrayLen) tree.nodes[idx].viewIndex = qMax(0, tree.nodes[idx].arrayLen - 1); } } }, command); refresh(); } void RcxController::setNodeValue(int nodeIdx, int subLine, const QString& text, bool isAscii) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; if (!m_doc->provider->isWritable()) return; const Node& node = m_doc->tree.nodes[nodeIdx]; uint64_t addr = m_doc->tree.computeOffset(nodeIdx); // For vector sub-components, redirect to float parsing at sub-offset NodeKind editKind = node.kind; if ((node.kind == NodeKind::Vec2 || node.kind == NodeKind::Vec3 || node.kind == NodeKind::Vec4) && subLine >= 0) { addr += subLine * 4; editKind = NodeKind::Float; } bool ok; QByteArray newBytes; if (isAscii) { int expectedSize = sizeForKind(editKind); newBytes = fmt::parseAsciiValue(text, expectedSize, &ok); } else { newBytes = fmt::parseValue(editKind, text, &ok); } if (!ok) return; // For strings, pad/truncate to full buffer size if (node.kind == NodeKind::UTF8 || node.kind == NodeKind::UTF16) { int fullSize = node.byteSize(); newBytes = newBytes.left(fullSize); if (newBytes.size() < fullSize) newBytes.append(QByteArray(fullSize - newBytes.size(), '\0')); } if (newBytes.isEmpty()) return; int writeSize = newBytes.size(); // Validate write range before pushing command if (!m_doc->provider->isReadable(addr, writeSize)) return; QByteArray oldBytes = m_doc->provider->readBytes(addr, writeSize); m_doc->undoStack.push(new RcxCommand(this, cmd::WriteBytes{addr, oldBytes, newBytes})); } void RcxController::duplicateNode(int nodeIdx) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; const Node& src = m_doc->tree.nodes[nodeIdx]; if (src.kind == NodeKind::Struct || src.kind == NodeKind::Array) return; insertNode(src.parentId, src.offset + src.byteSize(), src.kind, src.name + "_copy"); } void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, int subLine, const QPoint& globalPos) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; uint64_t clickedId = m_doc->tree.nodes[nodeIdx].id; // Right-click selection policy: if not in selection, select only this node if (!m_selIds.contains(clickedId)) { m_selIds.clear(); m_selIds.insert(clickedId); m_anchorLine = line; applySelectionOverlays(); } // Multi-select batch menu if (m_selIds.size() > 1) { QMenu menu; int count = m_selIds.size(); QSet ids = m_selIds; menu.addAction(QString("Delete %1 nodes").arg(count), [this, ids]() { QVector indices; for (uint64_t id : ids) { int idx = m_doc->tree.indexOfId(id); if (idx >= 0) indices.append(idx); } batchRemoveNodes(indices); }); menu.addAction(QString("Change type of %1 nodes...").arg(count), [this, ids]() { QStringList types; for (const auto& e : kKindMeta) types << e.name; bool ok; QString sel = QInputDialog::getItem(nullptr, "Change Type", "Type:", types, 0, false, &ok); if (ok) { QVector indices; for (uint64_t id : ids) { int idx = m_doc->tree.indexOfId(id); if (idx >= 0) indices.append(idx); } batchChangeKind(indices, kindFromString(sel)); } }); menu.exec(globalPos); return; } const Node& node = m_doc->tree.nodes[nodeIdx]; uint64_t nodeId = node.id; uint64_t parentId = node.parentId; QMenu menu; // Inline edit actions — position cursor on the right-clicked line bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array && node.kind != NodeKind::Padding && node.kind != NodeKind::Mat4x4 && m_doc->provider->isWritable(); if (isEditable) { menu.addAction("Edit &Value\tEnter", [editor, line]() { editor->beginInlineEdit(EditTarget::Value, line); }); } menu.addAction("Re&name\tF2", [editor, line]() { editor->beginInlineEdit(EditTarget::Name, line); }); menu.addAction("Change &Type\tT", [editor, line]() { editor->beginInlineEdit(EditTarget::Type, line); }); menu.addSeparator(); menu.addAction("&Add Field Below\tInsert", [this, parentId]() { insertNode(parentId, -1, NodeKind::Hex64, "newField"); }); if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) { menu.addAction("Add &Child", [this, nodeId]() { insertNode(nodeId, 0, NodeKind::Hex64, "newField"); }); QString colText = node.collapsed ? "&Expand" : "&Collapse"; menu.addAction(colText, [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni >= 0) toggleCollapse(ni); }); } menu.addAction("D&uplicate\tCtrl+D", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni >= 0) duplicateNode(ni); }); menu.addAction("&Delete\tDelete", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni >= 0) removeNode(ni); }); menu.addSeparator(); menu.addAction("Copy &Address", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni < 0) return; uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni); QApplication::clipboard()->setText( QStringLiteral("0x") + QString::number(addr, 16).toUpper()); }); menu.addAction("Copy &Offset", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni < 0) return; int off = m_doc->tree.nodes[ni].offset; QApplication::clipboard()->setText( QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0')); }); menu.addAction("Copy All as &Text", [editor]() { QApplication::clipboard()->setText(editor->scintilla()->text()); }); menu.exec(globalPos); } void RcxController::batchRemoveNodes(const QVector& nodeIndices) { QSet idSet; for (int idx : nodeIndices) { if (idx >= 0 && idx < m_doc->tree.nodes.size()) idSet.insert(m_doc->tree.nodes[idx].id); } idSet = m_doc->tree.normalizePreferAncestors(idSet); if (idSet.isEmpty()) return; // Clear selection before delete (prevents stale highlight on shifted lines) m_selIds.clear(); m_anchorLine = -1; m_doc->undoStack.beginMacro(QString("Delete %1 nodes").arg(idSet.size())); for (uint64_t id : idSet) { int idx = m_doc->tree.indexOfId(id); if (idx >= 0) removeNode(idx); } m_doc->undoStack.endMacro(); } void RcxController::batchChangeKind(const QVector& nodeIndices, NodeKind newKind) { QSet idSet; for (int idx : nodeIndices) { if (idx >= 0 && idx < m_doc->tree.nodes.size()) idSet.insert(m_doc->tree.nodes[idx].id); } 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); if (idx >= 0) changeNodeKind(idx, newKind); } m_doc->undoStack.endMacro(); } void RcxController::handleNodeClick(RcxEditor* source, int line, uint64_t nodeId, Qt::KeyboardModifiers mods) { bool ctrl = mods & Qt::ControlModifier; bool shift = mods & Qt::ShiftModifier; // Compute effective selection ID: footers use nodeId | kFooterIdBit auto effectiveId = [this](int ln, uint64_t nid) -> uint64_t { if (ln >= 0 && ln < m_lastResult.meta.size() && m_lastResult.meta[ln].lineKind == LineKind::Footer) return nid | kFooterIdBit; return nid; }; uint64_t selId = effectiveId(line, nodeId); if (!ctrl && !shift) { m_selIds.clear(); m_selIds.insert(selId); m_anchorLine = line; } else if (ctrl && !shift) { if (m_selIds.contains(selId)) m_selIds.remove(selId); else m_selIds.insert(selId); m_anchorLine = line; } else if (shift && !ctrl) { if (m_anchorLine < 0) { m_selIds.clear(); m_selIds.insert(selId); m_anchorLine = line; } else { m_selIds.clear(); int from = qMin(m_anchorLine, line); int to = qMax(m_anchorLine, line); for (int i = from; i <= to && i < m_lastResult.meta.size(); i++) { uint64_t nid = m_lastResult.meta[i].nodeId; if (nid != 0) m_selIds.insert(effectiveId(i, nid)); } } } else { // Ctrl+Shift if (m_anchorLine < 0) { m_selIds.insert(selId); m_anchorLine = line; } else { int from = qMin(m_anchorLine, line); int to = qMax(m_anchorLine, line); for (int i = from; i <= to && i < m_lastResult.meta.size(); i++) { uint64_t nid = m_lastResult.meta[i].nodeId; if (nid != 0) m_selIds.insert(effectiveId(i, nid)); } } } applySelectionOverlays(); updateCommandRow(); if (m_selIds.size() == 1) { uint64_t sid = *m_selIds.begin(); // Strip footer bit for node lookup int idx = m_doc->tree.indexOfId(sid & ~kFooterIdBit); if (idx >= 0) emit nodeSelected(idx); } } void RcxController::clearSelection() { m_selIds.clear(); m_anchorLine = -1; applySelectionOverlays(); updateCommandRow(); } void RcxController::applySelectionOverlays() { for (auto* editor : m_editors) editor->applySelectionOverlay(m_selIds); } void RcxController::updateCommandRow() { // -- Source label: driven by provider metadata -- QString src; QString provName = m_doc->provider->name(); if (provName.isEmpty()) { src = QStringLiteral("