From 3978a4e8e4bb1fcf1224702e909042668a94d557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=A4rkl?= Date: Tue, 30 Jun 2026 19:57:59 +0200 Subject: [PATCH 1/6] Use x86 image for x86 macos builds (macOS 15, dev) (#3639) --- .github/workflows/ci.yml | 2 +- scripts/fetch_deps.sh | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3c84ca..3fe31656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -397,7 +397,7 @@ jobs: python-version: 3.12.x output-id: artifact_windows - name: macos-x86_64 - os: macos-14 + os: macos-15-intel # Must be an x86_64 image: https://github.com/actions/runner-images#available-images arch: x86_64 package: true - name: macos-arm64 diff --git a/scripts/fetch_deps.sh b/scripts/fetch_deps.sh index a6281d34..0753023f 100755 --- a/scripts/fetch_deps.sh +++ b/scripts/fetch_deps.sh @@ -5,7 +5,7 @@ set -e cd $(dirname "${BASH_SOURCE[0]}")/.. mkdir -p cutter-deps && cd cutter-deps -DEPS_BASE_URL=https://github.com/rizinorg/cutter-deps/releases/download/v18 +DEPS_BASE_URL=https://github.com/rizinorg/cutter-deps/releases/download/v19 if [ "$CUTTER_QT" == "5" ]; then DEPS_FILE_linux_x86_64=cutter-deps-q5-linux-x86_64.tar.gz @@ -13,18 +13,18 @@ if [ "$CUTTER_QT" == "5" ]; then DEPS_BASE_URL=https://github.com/rizinorg/cutter-deps/releases/download/qt5-v17 else DEPS_FILE_linux_x86_64=cutter-deps-linux-x86_64.tar.gz - DEPS_SHA256_linux_x86_64=204683c44e4824df7cb9925f1939544f081e64b7a701fe95fd03f67125a165b4 + DEPS_SHA256_linux_x86_64=b4bebf95c69f6216456d31b345d660ae3347517a6c14a4a4e9d568b03289c3de fi echo $DEPS_SHA256_linux_x86_64 DEPS_FILE_macos_x86_64=cutter-deps-macos-x86_64.tar.gz -DEPS_SHA256_macos_x86_64=a46c2dee12d4160410237da848075fc87c41c3850021f583d3b469a59c386650 +DEPS_SHA256_macos_x86_64=6f202bd2b18cae780d4fe4ddce662909eebb4f0cd3f1c685e5b2bb6cd46a3e7d DEPS_FILE_macos_arm64=cutter-deps-macos-arm64.tar.gz -DEPS_SHA256_macos_arm64=1ca381b562d632a370e05a85b0fe60a591a261bba0f48d97a0e40b4307c2577a +DEPS_SHA256_macos_arm64=fa4b3c58f02e7b57a18c2fe7b8c196dd008187deda72fa1ae685c8ed661e10c6 DEPS_FILE_win_x86_64=cutter-deps-win-x86_64.tar.gz -DEPS_SHA256_win_x86_64=95a3cf676178b8c58442d22ee8b21ce2530bfdae86f514901310490a8f28867a +DEPS_SHA256_win_x86_64=3d69280bfedfdd3363fac450f814fc2f09cdd6149f60541dcdaa81f782c3ac76 ARCH=x86_64 From 2c6c838a135eedd7c0c1a714db548090cab0ff59 Mon Sep 17 00:00:00 2001 From: Andersen Dsa Date: Thu, 2 Jul 2026 13:53:57 +0530 Subject: [PATCH 2/6] Add viewport intersection check to cull invisible GraphView edges (#3619) --- src/widgets/GraphView.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/widgets/GraphView.cpp b/src/widgets/GraphView.cpp index 2aa66a9f..e7c6ba11 100644 --- a/src/widgets/GraphView.cpp +++ b/src/widgets/GraphView.cpp @@ -349,13 +349,16 @@ void GraphView::paint(QPainter &p, QPoint offset, QRect viewport, qreal scale, b p.setBrush(Qt::gray); - // Always draw edges - // TODO: Only draw edges if they are actually visible ... // Draw edges for (GraphEdge &edge : block.edges) { if (edge.polyline.empty()) { continue; } + // Skips expensive polyline and arrow rendering if the edge's bounding box + // is completely outside the current viewport. + if (!edge.polyline.boundingRect().adjusted(-1, -1, 1, 1).intersects(windowF)) { + continue; + } const QPolygonF polyline = edge.polyline; const EdgeConfiguration ec = edgeConfiguration(block, &blocks[edge.target], interactive); From e01d86b5fa12542b7eed9e35ce7d597b6b0a31ee Mon Sep 17 00:00:00 2001 From: Anton Bolshakov Date: Thu, 2 Jul 2026 19:43:10 +0800 Subject: [PATCH 3/6] cmake: add CUTTER_ENABLE_TRANSLATIONS option to skip translation build (#3640) Allows building and installing Cutter without translation files by passing -DCUTTER_ENABLE_TRANSLATIONS=OFF to CMake. This is useful when building from a source snapshot (e.g. a GitHub commit archive) that does not include the .ts files, or when the Qt linguist tools are not available. --- CMakeLists.txt | 1 + src/CMakeLists.txt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d6289c88..e785237f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,7 @@ set(CUTTER_EXTRA_PLUGIN_DIRS "" CACHE STRING "List of addition plugin locations" option(CUTTER_ENABLE_DEPENDENCY_DOWNLOADS "Enable downloading of dependencies. Setting to OFF doesn't affect any downloads done by rizin build." OFF) option(CUTTER_ENABLE_PACKAGING "Enable building platform-specific packages for distributing" OFF) option(CUTTER_ENABLE_SIGDB "Downloads and installs sigdb (only available when CUTTER_USE_BUNDLED_RIZIN=ON)." OFF) +option(CUTTER_ENABLE_TRANSLATIONS "Build and install translation files" ON) option(CUTTER_PACKAGE_DEPENDENCIES "During install step include the third party dependencies." OFF) option(CUTTER_PACKAGE_RZ_GHIDRA "Compile and install rz-ghidra during install step." OFF) option(CUTTER_PACKAGE_RZ_LIBSWIFT "Compile and install rz-libswift demangler during the install step." OFF) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 58b2b3b8..4fba081a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -613,7 +613,9 @@ if(CUTTER_ENABLE_PACKAGING) target_compile_definitions(Cutter PRIVATE CUTTER_ENABLE_PACKAGING) endif() -include(Translations) +if(CUTTER_ENABLE_TRANSLATIONS) + include(Translations) +endif() # Install files install(TARGETS Cutter From 867c54e838efcd4caac51f461949d8a048c40a26 Mon Sep 17 00:00:00 2001 From: Premade <128969155+PremadeS@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:52:28 +0500 Subject: [PATCH 4/6] Optimize disassembly scroll and selection logic (#3646) 1. Scroll by visual lines instead of disassembly lines (see #3604) Previously whenever a scroll happened, cutter cleared all of the disassembled lines it had and queried rizin again, even if the scroll was only for one instruction. This causes two problems: * All of the lines have to be queried again making scrolling slow. * Since an instruction can have multiple metadata lines attached to it, it meant that whenever a user scrolled a single instruction all of the metadata lines will also be scrolled - making scrolling feel choppy. To solve this we keep a buffer of "max visible lines * 5" which is filled in as user scrolls, meaning at first only "max visible lines" are queried from rizin, if user scrolls upwards the entire "max visible lines" above the current top instruction is fetched and prepended to our "lines" buffer. Since the previous lines are still saved - if the user scrolls back down we can just show those specific lines from our buffer instead of querying again. Lines are erased from start or end based on scroll direction if the buffer exceeds the size cap of "max visible lines * 5" The "lines" buffer is fully cleared if user seeks to some address using some external signal - like the Visual nav bar at the top (basically seeking using any method other than directly clicking on the instruction line itself) 2. Adds infinite selection via mouse and keyboard There was no way in cutter to select more lines than whats currently shown on screen and there was also no way to select text via keyboard. This is done via manually handling the selection instead of letting Qt handle it. The selection is preserved no matter how far user scrolls up or down. 3. Fixes empty space at the bottom of disassembly widget viewport Whenever "max visible lines" were calculated it didn't account for the fact that the last line might be partially visible meaning there is not enough space to display it fully, which created empty space at the bottom 4. Adds a visible/blinking cursor to the disassembly panel Avoids the issue of not knowing which line we are on, if the line contains similar text that is highlighted. Also helps to know where selection will start using physical keys --- src/common/Helpers.cpp | 48 +- src/shortcuts/DefaultShortcuts.cpp | 8 + src/widgets/DisassemblyWidget.cpp | 859 +++++++++++++++++++++++------ src/widgets/DisassemblyWidget.h | 124 ++++- 4 files changed, 848 insertions(+), 191 deletions(-) diff --git a/src/common/Helpers.cpp b/src/common/Helpers.cpp index d50bd5b4..0f94847b 100644 --- a/src/common/Helpers.cpp +++ b/src/common/Helpers.cpp @@ -4,19 +4,55 @@ #include #include +#include #include #include #include #include +#include #include #include #include +#include #include #include #include #include +namespace { +template +int calculateMaxDisplayedLines(TextEditType *textEdit) +{ + qreal lineHeight = 0; + const QTextBlock firstBlock = textEdit->document()->begin(); + if (firstBlock.isValid()) { + lineHeight = textEdit->document()->documentLayout()->blockBoundingRect(firstBlock).height(); + } + + if (lineHeight <= 0) { + const QFontMetricsF fm(textEdit->font()); + lineHeight = fm.lineSpacing(); + } + + qreal availableHeight = textEdit->viewport()->height(); + const qreal margin = textEdit->document()->documentMargin(); + availableHeight -= margin; + + const qreal exactLines = availableHeight / lineHeight; + const qreal floorValue = std::floor(exactLines); + const qreal fractionalPart = exactLines - floorValue; + + // If the last line is more than 15% visible then count it, otherwise leave it + // avoids the issue where there is empty space at the bottom of a textedit widget + if (fractionalPart >= 0.15) { + return std::ceil(availableHeight / lineHeight); + } + + return floorValue; +} +} + namespace qhelpers { QString formatByteCount(ut64 bytecount) @@ -174,20 +210,12 @@ void SizePolicyMinMax::restoreHeight(QWidget *widget) const int getMaxFullyDisplayedLines(QTextEdit *textEdit) { - const QFontMetrics fontMetrics(textEdit->document()->defaultFont()); - return (textEdit->height() - - (textEdit->contentsMargins().top() + textEdit->contentsMargins().bottom() - + (int)(textEdit->document()->documentMargin() * 2))) - / fontMetrics.lineSpacing(); + return calculateMaxDisplayedLines(textEdit); } int getMaxFullyDisplayedLines(QPlainTextEdit *plainTextEdit) { - const QFontMetrics fontMetrics(plainTextEdit->document()->defaultFont()); - return (plainTextEdit->height() - - (plainTextEdit->contentsMargins().top() + plainTextEdit->contentsMargins().bottom() - + (int)(plainTextEdit->document()->documentMargin() * 2))) - / fontMetrics.lineSpacing(); + return calculateMaxDisplayedLines(plainTextEdit); } QByteArray applyColorToSvg(const QByteArray &data, QColor color) diff --git a/src/shortcuts/DefaultShortcuts.cpp b/src/shortcuts/DefaultShortcuts.cpp index f93d7c2b..2ef4496b 100644 --- a/src/shortcuts/DefaultShortcuts.cpp +++ b/src/shortcuts/DefaultShortcuts.cpp @@ -215,6 +215,10 @@ const QHash &getDefaultShortcuts() { { Qt::Key_Space }, QT_TRANSLATE_NOOP("DisassemblyWidget", "Switch to Graph"), "DisassemblyWidget" } }, + { "Disassembly.moveLeft", + { QList { Qt::Key_H } + + QKeySequence::keyBindings(QKeySequence::MoveToPreviousChar), + QT_TRANSLATE_NOOP("DisassemblyWidget", "Move Cursor Down"), "DisassemblyWidget" } }, { "Disassembly.moveDown", { QList { Qt::Key_J } + QKeySequence::keyBindings(QKeySequence::MoveToNextLine), @@ -223,6 +227,10 @@ const QHash &getDefaultShortcuts() { QList { Qt::Key_K } + QKeySequence::keyBindings(QKeySequence::MoveToPreviousLine), QT_TRANSLATE_NOOP("DisassemblyWidget", "Move Cursor Up"), "DisassemblyWidget" } }, + { "Disassembly.moveRight", + { QList { Qt::Key_L } + + QKeySequence::keyBindings(QKeySequence::MoveToNextChar), + QT_TRANSLATE_NOOP("DisassemblyWidget", "Move Cursor Up"), "DisassemblyWidget" } }, { "Disassembly.pageDown", { QKeySequence::keyBindings(QKeySequence::MoveToNextPage), QT_TRANSLATE_NOOP("DisassemblyWidget", "Move Cursor Down By Page"), diff --git a/src/widgets/DisassemblyWidget.cpp b/src/widgets/DisassemblyWidget.cpp index da0d49d1..87fbcb42 100644 --- a/src/widgets/DisassemblyWidget.cpp +++ b/src/widgets/DisassemblyWidget.cpp @@ -13,6 +13,7 @@ #include "widgets/AddressRangeScrollBar.h" #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include @@ -100,8 +102,6 @@ DisassemblyWidget::DisassemblyWidget(MainWindow *main) setupFonts(); setupColors(); - updateMaxLines(); - mDisasTextEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); mDisasTextEdit->setFont(Config()->getFont()); mDisasTextEdit->setReadOnly(true); @@ -140,18 +140,27 @@ DisassemblyWidget::DisassemblyWidget(MainWindow *main) connect(Core(), &CutterCore::functionRenamed, this, refresh); connect(Core(), &CutterCore::varsChanged, this, refresh); connect(Core(), &CutterCore::asmOptionsChanged, this, refresh); - connect(Core(), &CutterCore::refreshCodeViews, this, refresh); + connect(Core(), &CutterCore::refreshCodeViews, this, [this] { + breakpointsDirty = true; + refreshDisasm(); + }); connect(Core(), &CutterCore::instructionChanged, this, &DisassemblyWidget::instructionChanged); - connect(Core(), &CutterCore::breakpointsChanged, this, &DisassemblyWidget::refreshIfInRange); + connect(Core(), &CutterCore::breakpointsChanged, this, [this](RVA offset) { + breakpointsDirty = true; + refreshIfInRange(offset); + }); connect(Config(), &Configuration::fontsUpdated, this, &DisassemblyWidget::fontsUpdatedSlot); connect(Config(), &Configuration::colorsUpdated, this, &DisassemblyWidget::colorsUpdatedSlot); - connect(Core(), &CutterCore::refreshAll, this, - [this]() { refreshDisasm(seekable->getOffset()); }); + connect(Core(), &CutterCore::refreshAll, this, [this]() { + // just in case if breakpoints were changed via rizin console + breakpointsDirty = true; + refreshDisasm(seekable->getOffset()); + }); refreshDisasm(seekable->getOffset()); - connect(mCtxMenu, &DisassemblyContextMenu::copy, mDisasTextEdit, &QPlainTextEdit::copy); + connect(mCtxMenu, &DisassemblyContextMenu::copy, this, &DisassemblyWidget::copySelection); mCtxMenu->addSeparator(); mCtxMenu->addAction(&syncAction); @@ -174,15 +183,16 @@ DisassemblyWidget::DisassemblyWidget(MainWindow *main) ADD_ACTION("General.seekPrev", Qt::WidgetWithChildrenShortcut, &DisassemblyWidget::seekPrev) - ADD_ACTION("Disassembly.moveDown", Qt::WidgetWithChildrenShortcut, - [this]() { moveCursorRelative(false, false); }) - ADD_ACTION("Disassembly.moveUp", Qt::WidgetWithChildrenShortcut, - [this]() { moveCursorRelative(true, false); }) - ADD_ACTION("Disassembly.pageDown", Qt::WidgetWithChildrenShortcut, - [this]() { moveCursorRelative(false, true); }) ADD_ACTION("Disassembly.pageUp", Qt::WidgetWithChildrenShortcut, - [this]() { moveCursorRelative(true, true); }) + [this] { moveCursorRelative(QTextCursor::Up, true); }); + + ADD_ACTION("Disassembly.pageDown", Qt::WidgetWithChildrenShortcut, + [this] { moveCursorRelative(QTextCursor::Down, true); }); + #undef ADD_ACTION + + QTimer::singleShot(0, [this] { updateMaxLines(); }); + connect(this, &CutterDockWidget::becameVisibleToUser, this, [this] { updateMaxLines(); }); } void DisassemblyWidget::setPreviewMode(bool previewMode) @@ -240,7 +250,7 @@ void DisassemblyWidget::instructionChanged(RVA offset) refreshDisasm(); } -void DisassemblyWidget::refreshDisasm(RVA offset) +void DisassemblyWidget::refreshDisasm(RVA offset, RefreshMode mode) { if (!disasmRefresh->attemptRefresh(offset == RVA_INVALID ? nullptr : new RVA(offset))) { return; @@ -261,30 +271,72 @@ void DisassemblyWidget::refreshDisasm(RVA offset) return; } - breakpoints = Core()->getBreakpointsAddresses(); + if (breakpointsDirty) { + breakpoints = Core()->getBreakpointsAddresses(); + breakpointsDirty = false; + } const int horizontalScrollValue = mDisasTextEdit->horizontalScrollBar()->value(); - mDisasTextEdit->setLockScroll(true); // avoid flicker + mDisasTextEdit->setLockScroll(true); // Retrieve disassembly lines { TempConfig tempConfig; tempConfig.set("scr.color", COLOR_MODE_16M).set("asm.lines", false); - lines = Core()->disassembleLines(topOffset, maxLines); + + if (mode == RefreshMode::Reset || lines.isEmpty()) { + lines = Core()->disassembleLines(topOffset, maxLines); + startIndex = 0; + } else if (mode == RefreshMode::Append) { + const RVA newOffset = Core()->nextOpAddr(lines.last().offset, 1); + lines.append(Core()->disassembleLines(newOffset, maxLines)); + + } else if (mode == RefreshMode::Prepend) { + const RVA newOffset = Core()->prevOpAddr(lines.first().offset, maxLines); + auto newLines = Core()->disassembleLines(newOffset, maxLines); + + // remove duplicates if any + if (!newLines.isEmpty() && !lines.isEmpty()) { + int keepSize = newLines.size(); + const RVA firstOffset = lines.first().offset; + for (int i = newLines.size() - 1; i >= 0; --i) { + if (newLines[i].offset >= firstOffset) { + keepSize = i; + } else { + break; + } + } + if (keepSize < newLines.size()) { + newLines.erase(newLines.begin() + keepSize, newLines.end()); + } + } + + const int prependCount = newLines.size(); + newLines.append(lines); + lines = std::move(newLines); + + startIndex += prependCount; + } } - connectCursorPositionChanged(true); + startIndex = qMax(0, qMin(startIndex, static_cast(lines.size()) - 1)); + endIndex = qMin(lines.size(), startIndex + maxLines); + // render lines + connectCursorPositionChanged(true); mDisasTextEdit->document()->clear(); QTextCursor cursor(mDisasTextEdit->document()); + cursor.beginEditBlock(); + const QTextBlockFormat regular = cursor.blockFormat(); - for (const DisassemblyLine &line : lines) { - if (line.offset < topOffset) { // overflow - break; - } + const QColor breakpointBg(ConfigColor("gui.breakpoint_background")); + + for (int i = startIndex; i < endIndex; ++i) { + const auto &line = lines[i]; + cursor.insertHtml(line.text); if (Core()->isBreakpoint(breakpoints, line.offset)) { QTextBlockFormat f; - f.setBackground(ConfigColor("gui.breakpoint_background")); + f.setBackground(breakpointBg); cursor.setBlockFormat(f); } auto a = new DisassemblyTextBlockUserData(line); @@ -293,8 +345,10 @@ void DisassemblyWidget::refreshDisasm(RVA offset) cursor.setBlockFormat(regular); } + cursor.endEditBlock(); + if (!lines.isEmpty()) { - bottomOffset = lines[qMin(lines.size(), maxLines) - 1].offset; + bottomOffset = lines[endIndex - 1].offset; if (bottomOffset < topOffset) { bottomOffset = RVA_MAX; } @@ -302,24 +356,54 @@ void DisassemblyWidget::refreshDisasm(RVA offset) bottomOffset = topOffset; } + // update cursor position + connectCursorPositionChanged(false); + updateCursorPosition(); + connectCursorPositionChanged(true); + + // update cursor selection + const bool hasSelection = (selectionAnchorRVA != RVA_INVALID && selectionPosRVA != RVA_INVALID + && (selectionAnchorRVA != selectionPosRVA + || selectionAnchorSubIndex != selectionPosSubIndex + || selectionAnchorChar != selectionPosChar)); + if (hasSelection) { + updateSelection(); + } connectCursorPositionChanged(false); - updateCursorPosition(); - - // remove additional lines - QTextCursor tc = mDisasTextEdit->textCursor(); - tc.movePosition(QTextCursor::Start); - tc.movePosition(QTextCursor::Down, QTextCursor::MoveAnchor, maxLines - 1); - tc.movePosition(QTextCursor::EndOfLine); - tc.movePosition(QTextCursor::End, QTextCursor::KeepAnchor); - tc.removeSelectedText(); + // update ctx menu + updateContextMenuSelection(hasSelection); + // update scrollbars mDisasTextEdit->setLockScroll(false); mDisasTextEdit->horizontalScrollBar()->setValue(horizontalScrollValue); + mDisasTextEdit->verticalScrollBar()->setValue(0); + mDisasScrollArea->verticalScrollBar()->setPosition(topOffset); - // Refresh the left panel (trigger paintEvent) + // update left panel (trigger paint event) leftPanel->update(); + + // update buffer by erasing extra lines + constexpr int multiplier = 5; + const int targetSize = maxLines * multiplier; + if (lines.size() <= targetSize) { + return; + } + + // TODO: its fine for now but maybe in future this could be made more efficie + // if the complexity trade-off is bearable + const int idealKeepStart = startIndex - (targetSize - maxLines) / 2; + const int keepStart = + qMax(0, qMin(idealKeepStart, static_cast(lines.size() - targetSize))); + const int removeBottom = lines.size() - (keepStart + targetSize); + if (removeBottom > 0) { + lines.erase(lines.begin() + keepStart + targetSize, lines.begin() + lines.size()); + } + if (keepStart > 0) { + lines.erase(lines.begin(), lines.begin() + keepStart); + startIndex -= keepStart; + } } void DisassemblyWidget::scrollInstructions(int count, bool clampToScrollBarRange) @@ -328,16 +412,30 @@ void DisassemblyWidget::scrollInstructions(int count, bool clampToScrollBarRange return; } - RVA offset; - if (count > 0) { - offset = Core()->nextOpAddr(topOffset, count); - if (offset < topOffset) { - offset = RVA_MAX; - } + startIndex += count; + RefreshMode mode = RefreshMode::Reset; + if (startIndex < 0) { + mode = RefreshMode::Prepend; + } else if (startIndex + maxLines > lines.size()) { + mode = RefreshMode::Append; + } else if (startIndex >= 0 && startIndex < lines.size()) { + mode = RefreshMode::None; + } + + RVA offset = topOffset; + if (mode == RefreshMode::None) { + offset = lines[startIndex].offset; } else { - offset = Core()->prevOpAddr(topOffset, -count); - if (offset > topOffset) { - offset = 0; + if (count > 0) { + offset = Core()->nextOpAddr(topOffset, count); + if (offset < topOffset) { + offset = RVA_MAX; + } + } else { + offset = Core()->prevOpAddr(topOffset, -count); + if (offset > topOffset) { + offset = 0; + } } } @@ -345,10 +443,169 @@ void DisassemblyWidget::scrollInstructions(int count, bool clampToScrollBarRange offset = mDisasScrollArea->verticalScrollBar()->clampAddressToRange(offset); } - refreshDisasm(offset); + refreshDisasm(offset, mode); topOffsetHistory[topOffsetHistoryPos] = offset; } +void DisassemblyWidget::updateSelection() +{ + int anchorRenderIndex = -1; + int posRenderIndex = -1; + + auto findRenderIndex = [&](RVA offset, int offsetBlockIndex) -> int { + // safely large number to clamp the highlight off-screen + // without causing integer overflow during UI math + constexpr int offscreenPadding = 100000; + + if (lines.isEmpty()) { + return 0; + } + if (offset < lines.first().offset) { + return -offscreenPadding; + } + if (offset > lines.last().offset) { + return lines.size() + offscreenPadding; + } + + const int exactIndex = getLineIndex(offset, offsetBlockIndex); + if (exactIndex != -1) { + return exactIndex; + } + + // fallback: if somehow between bounds but missing, return nearest insertion point + auto it = std::lower_bound( + lines.begin(), lines.end(), offset, + [](const DisassemblyLine &line, RVA target) { return line.offset < target; }); + return std::distance(lines.begin(), it); + }; + + anchorRenderIndex = findRenderIndex(selectionAnchorRVA, selectionAnchorSubIndex); + posRenderIndex = findRenderIndex(selectionPosRVA, selectionPosSubIndex); + + // If the entire selection is completely above or completely below the screen then do nothing + if ((anchorRenderIndex < startIndex && posRenderIndex < startIndex) + || (anchorRenderIndex >= endIndex && posRenderIndex >= endIndex)) { + mDisasTextEdit->textCursor().clearSelection(); + return; + } + + // clamp indices so they don't reach outside current viewport + const int clampedAnchorIndex = qBound(startIndex, anchorRenderIndex, endIndex - 1); + const int clampedPosIndex = qBound(startIndex, posRenderIndex, endIndex - 1); + + const QTextBlock anchorBlock = + mDisasTextEdit->document()->findBlockByNumber(clampedAnchorIndex - startIndex); + const QTextBlock posBlock = + mDisasTextEdit->document()->findBlockByNumber(clampedPosIndex - startIndex); + + if (anchorBlock.isValid() && posBlock.isValid()) { + int finalAnchorPos = anchorBlock.position(); + int finalPosPos = posBlock.position(); + + // if selection starts above visible lines/blocks then start the selection from first + // character of first block otherwise start from where the selection actually started + if (anchorRenderIndex >= startIndex) { + finalAnchorPos += qMin(selectionAnchorChar, qMax(0, anchorBlock.length() - 1)); + } + + // same as above but for selection end + if (posRenderIndex < endIndex) { + finalPosPos += qMin(selectionPosChar, qMax(0, posBlock.length() - 1)); + } else { + // The position is below the screen - select till end of the last visible block + finalPosPos += qMax(0, posBlock.length() - 1); + } + + QTextCursor restoreCursor(mDisasTextEdit->document()); + restoreCursor.setPosition(finalAnchorPos); + restoreCursor.setPosition(finalPosPos, QTextCursor::KeepAnchor); + mDisasTextEdit->setTextCursor(restoreCursor); + } +} + +void DisassemblyWidget::updateContextMenuSelection(bool hasSelection) +{ + mCtxMenu->setCanCopy(hasSelection); + if (hasSelection) { + mCtxMenu->setCurHighlightedWord(mDisasTextEdit->textCursor().selectedText()); + } else { + mCtxMenu->setCurHighlightedWord(curHighlightedWord); + } +} + +int DisassemblyWidget::getLineIndex(RVA offset, int offsetSubIndex) const +{ + if (lines.isEmpty()) { + return -1; + } + + auto it = std::lower_bound( + lines.begin(), lines.end(), offset, + [](const DisassemblyLine &line, RVA target) { return line.offset < target; }); + + if (it != lines.end() && it->offset == offset) { + const int baseIndex = std::distance(lines.begin(), it); + if (baseIndex + offsetSubIndex < lines.size() + && lines[baseIndex + offsetSubIndex].offset == offset) { + return baseIndex + offsetSubIndex; + } + return baseIndex; + } + return -1; +} + +void DisassemblyWidget::updateLineHighlights() +{ + QList combinedSelections; + combinedSelections.append(highlightCurrentLine()); + combinedSelections.append(highlightPCLine()); + mDisasTextEdit->setExtraSelections(combinedSelections); +} + +void DisassemblyWidget::invalidateCursorSelection() +{ + selectionAnchorRVA = RVA_INVALID; + selectionPosRVA = RVA_INVALID; + selectionAnchorSubIndex = 0; + selectionPosSubIndex = 0; + selectionAnchorChar = 0; + selectionPosChar = 0; +} + +int DisassemblyWidget::getIndexInOffsetGroup(const QTextCursor &cursor) const +{ + const int lineIndex = startIndex + cursor.blockNumber(); + if (lineIndex < 0 || lineIndex >= lines.size()) { + return 0; + } + + const RVA offset = lines[lineIndex].offset; + int subIndex = 0; + + for (int i = lineIndex - 1; i >= 0; --i) { + if (lines[i].offset == offset) { + subIndex++; + } else { + break; // offsets are contiguous + } + } + return subIndex; +} + +void DisassemblyWidget::updateSelectionPos(const QTextCursor &cursor) +{ + selectionPosRVA = DisHlp::readDisassemblyOffset(cursor); + selectionPosSubIndex = getIndexInOffsetGroup(cursor); + selectionPosChar = cursor.positionInBlock(); +} + +void DisassemblyWidget::updateSelectionAnchor(const QTextCursor &cursor) +{ + selectionAnchorRVA = DisHlp::readDisassemblyOffset(cursor); + selectionAnchorSubIndex = getIndexInOffsetGroup(cursor); + selectionAnchorChar = cursor.positionInBlock(); +} + bool DisassemblyWidget::updateMaxLines() { const int currentMaxLines = qhelpers::getMaxFullyDisplayedLines(mDisasTextEdit); @@ -362,42 +619,61 @@ bool DisassemblyWidget::updateMaxLines() return false; } -void DisassemblyWidget::highlightCurrentLine() +QList DisassemblyWidget::highlightCurrentLine() { QList extraSelections; const QColor highlightColor = ConfigColor("lineHighlight"); - // Highlight the current word + const RVA currentOffset = seekable->getOffset(); + + bool isOffScreen = true; + if (currentOffset >= topOffset + && (currentOffset <= bottomOffset || bottomOffset == RVA_INVALID)) { + const int targetLineIndex = getLineIndex(currentOffset, cursorLineOffset); + if (targetLineIndex >= startIndex && targetLineIndex < endIndex) { + isOffScreen = false; + } + } + QTextCursor cursor = mDisasTextEdit->textCursor(); - auto clickedCharPos = cursor.positionInBlock(); - // Select the line (BlockUnderCursor matches a line with current implementation) - cursor.select(QTextCursor::BlockUnderCursor); - // Remove any non-breakable space from the current line - const QString searchString = cursor.selectedText().replace("\xc2\xa0", " "); - // Cut the line in "tokens" that can be highlighted - static const QRegularExpression tokenRegExp(R"(\b(? clickedCharPos) { - curHighlightedWord = match.captured(); - break; + + if (!isOffScreen) { + if (cursor.hasSelection()) { + curHighlightedWord = cursor.selectedText(); + } else { + auto clickedCharPos = cursor.positionInBlock(); + // Select the line (BlockUnderCursor matches a line with current implementation) + cursor.select(QTextCursor::BlockUnderCursor); + // Remove any non-breakable space from the current line + const QString searchString = cursor.selectedText().replace("\xc2\xa0", " "); + // Cut the line in "tokens" that can be highlighted + static const QRegularExpression tokenRegExp(R"(\b(? clickedCharPos) { + curHighlightedWord = match.captured(); + break; + } + } } } // Highlight the current line QTextEdit::ExtraSelection highlightSelection; - highlightSelection.cursor = cursor; + highlightSelection.cursor = mDisasTextEdit->textCursor(); highlightSelection.cursor.movePosition(QTextCursor::Start); while (true) { const RVA lineOffset = DisHlp::readDisassemblyOffset(highlightSelection.cursor); - if (lineOffset == seekable->getOffset()) { + if (lineOffset == currentOffset) { highlightSelection.format.setBackground(highlightColor); highlightSelection.format.setProperty(QTextFormat::FullWidthSelection, true); highlightSelection.cursor.clearSelection(); extraSelections.append(highlightSelection); - } else if (lineOffset != RVA_INVALID && lineOffset > seekable->getOffset()) { + } else if (lineOffset != RVA_INVALID && lineOffset > currentOffset) { break; } highlightSelection.cursor.movePosition(QTextCursor::EndOfLine); @@ -409,12 +685,14 @@ void DisassemblyWidget::highlightCurrentLine() } // Highlight all the words in the document same as the current one - extraSelections.append(createSameWordsSelections(mDisasTextEdit, curHighlightedWord)); + if (!curHighlightedWord.isEmpty()) { + extraSelections.append(createSameWordsSelections(mDisasTextEdit, curHighlightedWord)); + } - mDisasTextEdit->setExtraSelections(extraSelections); + return extraSelections; } -void DisassemblyWidget::highlightPCLine() +QList DisassemblyWidget::highlightPCLine() { const RVA pcAddr = Core()->getProgramCounterValue(); @@ -444,11 +722,7 @@ void DisassemblyWidget::highlightPCLine() } } - // Don't override any extraSelections already set - QList currentSelections = mDisasTextEdit->extraSelections(); - currentSelections.append(pcSelections); - - mDisasTextEdit->setExtraSelections(currentSelections); + return pcSelections; } void DisassemblyWidget::showDisasContextMenu(const QPoint &pt) @@ -466,63 +740,37 @@ void DisassemblyWidget::updateCursorPosition() { const RVA offset = seekable->getOffset(); - // already fine where it is? - const RVA currentLineOffset = readCurrentDisassemblyOffset(); - if (currentLineOffset == offset) { - return; - } - connectCursorPositionChanged(true); + const int targetLineIndex = getLineIndex(offset, cursorLineOffset); + + bool isOffScreen = false; if (offset < topOffset || (offset > bottomOffset && bottomOffset != RVA_INVALID)) { - mDisasTextEdit->moveCursor(QTextCursor::Start); - mDisasTextEdit->setExtraSelections( - createSameWordsSelections(mDisasTextEdit, curHighlightedWord)); - } else { - const RVA currentCursorOffset = readCurrentDisassemblyOffset(); - const QTextCursor originalCursor = mDisasTextEdit->textCursor(); - - QTextCursor cursor = originalCursor; - cursor.movePosition(QTextCursor::Start); - - while (true) { - const RVA lineOffset = DisHlp::readDisassemblyOffset(cursor); - if (lineOffset == offset) { - if (cursorLineOffset > 0) { - cursor.movePosition(QTextCursor::Down, QTextCursor::MoveAnchor, - cursorLineOffset); - } - if (cursorCharOffset > 0) { - cursor.movePosition(QTextCursor::StartOfLine); - cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, - cursorCharOffset); - } - - mDisasTextEdit->setTextCursor(cursor); - highlightCurrentLine(); - break; - } else if (lineOffset != RVA_INVALID && lineOffset > offset) { - mDisasTextEdit->moveCursor(QTextCursor::Start); - mDisasTextEdit->setExtraSelections({}); - break; - } - - cursor.movePosition(QTextCursor::EndOfLine); - if (cursor.atEnd()) { - break; - } - - cursor.movePosition(QTextCursor::Down); - } - - // this is true if a seek came from the user clicking on a line. - // then the cursor should be restored 1:1 to retain selection and cursor position. - if (currentCursorOffset == offset) { - mDisasTextEdit->setTextCursor(originalCursor); - } + isOffScreen = true; + } else if (targetLineIndex < startIndex || targetLineIndex >= endIndex) { + isOffScreen = true; } - highlightPCLine(); + if (isOffScreen) { + mDisasTextEdit->moveCursor(QTextCursor::Start); + mDisasTextEdit->setCursorVisible(false); + } else { + const int targetBlockNum = targetLineIndex - startIndex; + QTextCursor cursor = mDisasTextEdit->textCursor(); + cursor.movePosition(QTextCursor::Start); + if (targetBlockNum > 0) { + cursor.movePosition(QTextCursor::Down, QTextCursor::MoveAnchor, targetBlockNum); + } + if (cursorCharOffset > 0) { + cursor.movePosition(QTextCursor::StartOfLine); + cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, cursorCharOffset); + } + + mDisasTextEdit->setTextCursor(cursor); + mDisasTextEdit->setCursorVisible(true); + } + + updateLineHighlights(); connectCursorPositionChanged(false); } @@ -542,35 +790,137 @@ void DisassemblyWidget::cursorPositionChanged() { const RVA offset = readCurrentDisassemblyOffset(); - cursorLineOffset = 0; - QTextCursor c = mDisasTextEdit->textCursor(); + const auto c = mDisasTextEdit->textCursor(); cursorCharOffset = c.positionInBlock(); - while (c.blockNumber() > 0) { - c.movePosition(QTextCursor::PreviousBlock); - if (DisHlp::readDisassemblyOffset(c) != offset) { - break; - } - cursorLineOffset++; - } + cursorLineOffset = getIndexInOffsetGroup(c); seekFromCursor = true; seekable->seek(offset); seekFromCursor = false; - highlightCurrentLine(); - highlightPCLine(); - mCtxMenu->setCanCopy(mDisasTextEdit->textCursor().hasSelection()); - if (mDisasTextEdit->textCursor().hasSelection()) { - // A word is selected so use it - mCtxMenu->setCurHighlightedWord(mDisasTextEdit->textCursor().selectedText()); - } else { - // No word is selected so use the word under the cursor - mCtxMenu->setCurHighlightedWord(curHighlightedWord); - } + + updateLineHighlights(); + updateContextMenuSelection(mDisasTextEdit->textCursor().hasSelection()); leftPanel->update(); } -void DisassemblyWidget::moveCursorRelative(bool up, bool page) +void DisassemblyWidget::copySelection() { + if (selectionAnchorRVA == RVA_INVALID || selectionPosRVA == RVA_INVALID) { + mDisasTextEdit->copy(); + return; + } + + // determine direction (user could have dragged upwards) + bool forward = true; + if (selectionAnchorRVA > selectionPosRVA) { + forward = false; + } else if (selectionAnchorRVA == selectionPosRVA) { + forward = (selectionAnchorChar <= selectionPosChar); + } + + const RVA startRVA = forward ? selectionAnchorRVA : selectionPosRVA; + const RVA endRVA = forward ? selectionPosRVA : selectionAnchorRVA; + const int startChar = forward ? selectionAnchorChar : selectionPosChar; + const int endChar = forward ? selectionPosChar : selectionAnchorChar; + const int startRVAIndex = forward ? selectionAnchorSubIndex : selectionPosSubIndex; + const int endRVAIndex = forward ? selectionPosSubIndex : selectionAnchorSubIndex; + + QStringList plainTextLines; + QTextDocument htmlStripper; + + // check if the entire selection is currently sitting in 'lines' buffer + int selectionStart = -1, selectionEnd = -1; + if (!lines.isEmpty() && startRVA >= lines.first().offset && endRVA <= lines.last().offset) { + selectionStart = getLineIndex(startRVA, startRVAIndex); + selectionEnd = getLineIndex(endRVA, endRVAIndex); + } + + // extract text + if (selectionStart != -1 && selectionEnd != -1 && selectionStart <= selectionEnd) { + for (int i = selectionStart; i <= selectionEnd; ++i) { + htmlStripper.setHtml(lines[i].text); + plainTextLines.append(htmlStripper.toPlainText()); + } + } else { + // selection exceeds buffer + TempConfig tempConfig; + tempConfig.set("scr.color", COLOR_MODE_DISABLED).set("asm.lines", false); + + RVA currentRVA = startRVA; + int currentStartHit = 0; + int currentEndHit = 0; + + while (currentRVA <= endRVA && currentRVA != RVA_INVALID) { + const auto fetchedLines = Core()->disassembleLines(currentRVA, maxLines); + if (fetchedLines.isEmpty()) { + break; + } + + for (const auto &line : fetchedLines) { + // skip unselected duplicate lines at the top + if (line.offset == startRVA && currentStartHit < startRVAIndex) { + currentStartHit++; + continue; + } + + if (line.offset > endRVA) { + currentRVA = RVA_INVALID; // reached target + break; + } + + plainTextLines.append(QString(line.text).replace(" ", " ")); + + if (line.offset == endRVA) { + if (currentEndHit == endRVAIndex) { + currentRVA = RVA_INVALID; // reached target + break; + } + currentEndHit++; + } + } + + if (currentRVA != RVA_INVALID) { + currentRVA = Core()->nextOpAddr(fetchedLines.last().offset, 1); + } + } + } + + if (plainTextLines.isEmpty()) { + return; + } + + if (plainTextLines.size() == 1) { + // single-line selection + const int len = qMax(0, endChar - startChar); + plainTextLines[0] = plainTextLines[0].mid(startChar, len); + } else { + // multi-line selection + if (startChar > 0) { + plainTextLines[0] = plainTextLines[0].mid(startChar); + } + if (endChar >= 0 && endChar < plainTextLines.last().length()) { + plainTextLines.last() = plainTextLines.last().left(endChar); + } + } + + const QString finalCopiedText = plainTextLines.join("\n"); + QApplication::clipboard()->setText(finalCopiedText); +} + +void DisassemblyWidget::moveCursorRelative(QTextCursor::MoveOperation op, bool page) +{ + const bool up = op == QTextCursor::Up; + const bool select = QApplication::keyboardModifiers() & Qt::ShiftModifier; + const bool hadSelection = (selectionAnchorRVA != RVA_INVALID && selectionPosRVA != RVA_INVALID); + + if (select) { + if (!hadSelection) { + updateSelectionAnchor(mDisasTextEdit->textCursor()); + } + } else { + invalidateCursorSelection(); + } + if (page) { RVA offset; if (!up) { @@ -605,28 +955,53 @@ void DisassemblyWidget::moveCursorRelative(bool up, bool page) } } refreshDisasm(offset); - } else { // normal arrow keys - const int blockCount = mDisasTextEdit->blockCount(); - if (blockCount < 1) { + + if (select) { + const auto newCursor = mDisasTextEdit->textCursor(); + selectionPosRVA = DisHlp::readDisassemblyOffset(newCursor); + selectionPosSubIndex = getIndexInOffsetGroup(newCursor); + selectionPosChar = newCursor.positionInBlock(); + refreshDisasm(RVA_INVALID, RefreshMode::None); + } else if (hadSelection) { + refreshDisasm(RVA_INVALID, RefreshMode::None); + } + } else { + if (op == QTextCursor::Left || op == QTextCursor::Right) { + mDisasTextEdit->moveCursor(op, + select ? QTextCursor::KeepAnchor : QTextCursor::MoveAnchor); + + if (select) { + updateSelectionPos(mDisasTextEdit->textCursor()); + refreshDisasm(RVA_INVALID, RefreshMode::None); + } else if (hadSelection) { + refreshDisasm(RVA_INVALID, RefreshMode::None); + } return; } const int blockNumber = mDisasTextEdit->textCursor().blockNumber(); + const int count = endIndex - startIndex; - if (blockNumber == blockCount - 1 && !up) { - scrollInstructions(1); - } else if (blockNumber == 0 && up) { + if (up && blockNumber <= 0) { scrollInstructions(-1); + } else if (op == QTextCursor::Down && blockNumber >= count - 1) { + scrollInstructions(1); } - mDisasTextEdit->moveCursor(up ? QTextCursor::Up : QTextCursor::Down); + mDisasTextEdit->moveCursor(op, select ? QTextCursor::KeepAnchor : QTextCursor::MoveAnchor); + mDisasTextEdit->verticalScrollBar()->setValue(startIndex - startIndex); + + if (select) { + updateSelectionPos(mDisasTextEdit->textCursor()); + refreshDisasm(RVA_INVALID, RefreshMode::None); + } - // handle cases where top instruction offsets change const RVA offset = readCurrentDisassemblyOffset(); if (offset != seekable->getOffset()) { seekable->seek(offset); - highlightCurrentLine(); - highlightPCLine(); + updateLineHighlights(); + } else if (!select && hadSelection) { + refreshDisasm(RVA_INVALID, RefreshMode::None); } } } @@ -693,6 +1068,54 @@ void DisassemblyWidget::keyPressEvent(QKeyEvent *event) } else { jumpToOffsetUnderCursor(cursor); } + event->accept(); + return; + } + + // handle cursor shortcuts here because we need to check if shift is pressed for + // updating selection. + // shift select is not supported for page up and page down currently + bool handled = false; + QTextCursor::MoveOperation op; + + // fix for macos, since macos appends KeypadModifier to cursor keys pressed via keypad, it + // causes the QKeySequence::ExactMatch check to fail. + // We remove the modifier here + const Qt::KeyboardModifiers pureModifiers = event->modifiers() & ~Qt::KeypadModifier; + + const int baseKey = event->key() | (pureModifiers & ~Qt::ShiftModifier); + QKeySequence baseSeq(baseKey); + QKeySequence exactSeq(event->key() | event->modifiers()); + + auto matches = [&](const QString &id) { + for (const QKeySequence &seq : Shortcuts()->getKeySequences(id)) { + if (seq.matches(baseSeq) == QKeySequence::ExactMatch + || seq.matches(exactSeq) == QKeySequence::ExactMatch) { + return true; + } + } + return false; + }; + + if (matches("Disassembly.moveUp")) { + op = QTextCursor::Up; + handled = true; + } else if (matches("Disassembly.moveDown")) { + op = QTextCursor::Down; + handled = true; + } else if (matches("Disassembly.moveLeft")) { + op = QTextCursor::Left; + handled = true; + } else if (matches("Disassembly.moveRight")) { + op = QTextCursor::Right; + handled = true; + } + + if (handled) { + moveCursorRelative(op, false); + mDisasTextEdit->setCursorVisible(true); + event->accept(); + return; } MemoryDockWidget::keyPressEvent(event); @@ -714,6 +1137,16 @@ QString DisassemblyWidget::getWindowTitle() const return tr("Disassembly"); } +int DisassemblyWidget::getEndIndex() const +{ + return endIndex; +} + +int DisassemblyWidget::getStartIndex() const +{ + return startIndex; +} + void DisassemblyWidget::onSeekChanged(RVA offset, CutterCore::SeekHistoryType type) { if (type == CutterCore::SeekHistoryType::New) { @@ -732,6 +1165,10 @@ void DisassemblyWidget::onSeekChanged(RVA offset, CutterCore::SeekHistoryType ty if (!seekFromCursor) { cursorLineOffset = 0; cursorCharOffset = 0; + + // invalidate selection if the seek came from an external + // source (like seeking via VisualNavBar) + invalidateCursorSelection(); } if (topOffset != RVA_INVALID && offset >= topOffset && offset <= bottomOffset @@ -831,11 +1268,35 @@ void DisassemblyScrollArea::wheelEvent(QWheelEvent *event) emit wheelEventTriggered(); } +DisassemblyTextEdit::DisassemblyTextEdit(DisassemblyWidget *disasmWidget) + : QPlainTextEdit(disasmWidget), lockScroll(false), disasmWidget(disasmWidget) +{ + + blinkTimer = new QTimer(this); + connect(blinkTimer, &QTimer::timeout, this, [this]() { + cursorVisible = !cursorVisible; + viewport()->update(); + }); + blinkTimer->start(500); +} + qreal DisassemblyTextEdit::textOffset() const { return (blockBoundingGeometry(document()->begin()).topLeft() + contentOffset()).y(); } +void DisassemblyTextEdit::setCursorVisible(bool visible) +{ + if (visible) { + cursorVisible = true; + blinkTimer->start(500); + } else { + cursorVisible = false; + blinkTimer->stop(); + } + viewport()->update(); +} + bool DisassemblyTextEdit::viewportEvent(QEvent *event) { switch (event->type()) { @@ -861,6 +1322,16 @@ void DisassemblyTextEdit::keyPressEvent(QKeyEvent *event) void DisassemblyTextEdit::mousePressEvent(QMouseEvent *event) { + if (event->button() == Qt::LeftButton) { + + const QTextCursor cursor = cursorForPosition(event->pos()); + + disasmWidget->updateSelectionAnchor(cursor); + disasmWidget->updateSelectionPos(cursor); + + disasmWidget->refreshDisasm(RVA_INVALID, RefreshMode::None); + } + QPlainTextEdit::mousePressEvent(event); if (event->button() == Qt::RightButton && !textCursor().hasSelection()) { @@ -868,6 +1339,45 @@ void DisassemblyTextEdit::mousePressEvent(QMouseEvent *event) } } +void DisassemblyTextEdit::mouseMoveEvent(QMouseEvent *event) +{ + if (event->buttons() & Qt::LeftButton) { + + const QPoint pos = event->pos(); + const QTextCursor cursor = cursorForPosition(pos); + const RVA currentRVA = DisHlp::readDisassemblyOffset(cursor); + + if (currentRVA != RVA_INVALID) { + disasmWidget->updateSelectionPos(cursor); + } + + if (pos.y() < 0) { + disasmWidget->scrollInstructions(-1, true); + } else if (pos.y() > viewport()->height()) { + disasmWidget->scrollInstructions(1, true); + } else if (currentRVA != RVA_INVALID) { + disasmWidget->refreshDisasm(RVA_INVALID, RefreshMode::None); + } + + return; + } + + QPlainTextEdit::mouseMoveEvent(event); +} + +void DisassemblyTextEdit::paintEvent(QPaintEvent *event) +{ + QPlainTextEdit::paintEvent(event); + + if (cursorVisible) { + QPainter painter(viewport()); + QRect cRect = cursorRect(); + cRect.setWidth(2); + painter.setCompositionMode(QPainter::RasterOp_SourceXorDestination); + painter.fillRect(cRect, Qt::white); + } +} + void DisassemblyWidget::seekPrev() { Core()->seekPrev(); @@ -925,15 +1435,27 @@ void DisassemblyLeftPanel::paintEvent(QPaintEvent *event) cursor.movePosition(QTextCursor::Down); } + const int startIndex = disas->getStartIndex(); + const int endIndex = qMin(static_cast(lines.size()), disas->getEndIndex()); + const int maxLines = endIndex - startIndex; + + // Capture how many lines are ACTUALLY drawn on the screen right now + const int visibleCount = qMin(maxLines, static_cast(lines.size()) - startIndex); + if (visibleCount <= 0) { + return; + } + using LineInfo = std::pair; std::vector lineOffsets; - lineOffsets.reserve(lines.size() + arrows.size()); + lineOffsets.reserve(visibleCount + arrows.size()); - RVA minViewOffset = 0, maxViewOffset = 0; - minViewOffset = maxViewOffset = lines[0].offset; + RVA minViewOffset = lines[startIndex].offset; + RVA maxViewOffset = minViewOffset; - for (int i = 0; i < lines.size(); i++) { - lineOffsets.emplace_back(lines[i].offset, i); + for (int i = startIndex; i < endIndex; i++) { + // Map offset to a viewport-relative index (0 to maxLines) instead of absolute + // buffer index 'i' + lineOffsets.emplace_back(lines[i].offset, i - startIndex); minViewOffset = std::min(minViewOffset, lines[i].offset); maxViewOffset = std::max(maxViewOffset, lines[i].offset); if (lines[i].arrow != RVA_INVALID) { @@ -977,7 +1499,7 @@ void DisassemblyLeftPanel::paintEvent(QPaintEvent *event) [&](const LineInfo &line) { return line.first > maxViewOffset; }) - lineOffsets.begin(); for (size_t i = firstLineAfter; i < lineOffsets.size(); i++) { - lineOffsets[i].second = lines.size() + (i - firstLineAfter) + lineOffsets[i].second = visibleCount + (i - firstLineAfter) + 1; // +1 to ensure end of arrrow is drawn outside screen } @@ -997,12 +1519,11 @@ void DisassemblyLeftPanel::paintEvent(QPaintEvent *event) return res->second; }; - auto fitsInScreen = [&](const Arrow &a) { return maxViewOffset - minViewOffset < a.length(); }; - - std::sort(std::begin(arrows), std::end(arrows), [&](const Arrow &l, const Arrow &r) { - const int lScreen = fitsInScreen(l), rScreen = fitsInScreen(r); - if (lScreen != rScreen) { - return lScreen < rScreen; + std::sort(std::begin(arrows), std::end(arrows), [](const Arrow &l, const Arrow &r) { + const auto lLen = l.length(); + const auto rLen = r.length(); + if (lLen != rLen) { + return lLen < rLen; } return l.max != r.max ? l.max < r.max : l.min > r.min; }); @@ -1037,7 +1558,11 @@ void DisassemblyLeftPanel::paintEvent(QPaintEvent *event) const RVA currOffset = disas->getSeekable()->getOffset(); const qreal pixelRatio = qhelpers::devicePixelRatio(p.device()); - const Arrow visibleRange { lines.first().offset, lines.last().offset }; + + const int targetEndIndex = + qMin(static_cast(lines.size() - 1), startIndex + visibleCount - 1); + const Arrow visibleRange { lines.at(startIndex).offset, lines.at(targetEndIndex).offset }; + // Draw the lines for (const auto &arrow : arrows) { if (!visibleRange.intersects(arrow)) { diff --git a/src/widgets/DisassemblyWidget.h b/src/widgets/DisassemblyWidget.h index abd6d99b..a83b261f 100644 --- a/src/widgets/DisassemblyWidget.h +++ b/src/widgets/DisassemblyWidget.h @@ -19,6 +19,8 @@ class DisassemblyContextMenu; class DisassemblyLeftPanel; class AddressRangeScrollBar; +enum class RefreshMode : ut8 { Append, Prepend, Reset, Keep, None }; + /** * @brief Main widget for showing disassembly of a binary * @@ -33,48 +35,67 @@ public: static QString getWidgetType(); + QFontMetricsF getFontMetrics(); + QList getLines(); + + int getStartIndex() const; + int getEndIndex() const; + + /** + * @brief Updates the offset and character position where the cursor selection ends + */ + void updateSelectionPos(const QTextCursor &cursor); + + /** + * @brief Updates the offset and character position where the cursor selection starts + */ + void updateSelectionAnchor(const QTextCursor &cursor); + public slots: /** * @brief Highlights the currently selected line and updates the * highlighting of the same words under the cursor in the visible screen. - * This overrides all previous highlighting. + * @return List of selections to be highlighted */ - void highlightCurrentLine(); + QList highlightCurrentLine(); /** * @brief Adds the PC line highlighting to the other current highlighting. - * This should be called after highlightCurrentLine since that function - * overrides all previous highlighting. + * This is generally called after highlightCurrentLine + * @return List of selections to be highlighted */ - void highlightPCLine(); + QList highlightPCLine(); void showDisasContextMenu(const QPoint &pt); void fontsUpdatedSlot(); void colorsUpdatedSlot(); void scrollInstructions(int count, bool clampToScrollBarRange = false); void seekPrev(); void setPreviewMode(bool previewMode); - QFontMetricsF getFontMetrics(); - QList getLines(); /** * @brief Forces the transient vertical scrollbar to appear on scroll */ void showTransientScrollBar(); + + void refreshDisasm(RVA offset = RVA_INVALID, RefreshMode mode = RefreshMode::Reset); + protected slots: void onSeekChanged(RVA offset, CutterCore::SeekHistoryType type); void refreshIfInRange(RVA offset); void instructionChanged(RVA offset); - void refreshDisasm(RVA offset = RVA_INVALID); bool updateMaxLines(); void cursorPositionChanged(); + /** + * @brief Copies the currently highlighted disassembly text to the system clipboard + */ + void copySelection(); protected: DisassemblyContextMenu *mCtxMenu; DisassemblyScrollArea *mDisasScrollArea; DisassemblyTextEdit *mDisasTextEdit; DisassemblyLeftPanel *leftPanel; - QList lines; private: RVA topOffset; @@ -101,7 +122,31 @@ private: int topOffsetHistoryPos = 0; QList topOffsetHistory; + int startIndex = 0; + int endIndex = 0; + QList lines; + + // Cursor selection related + RVA selectionAnchorRVA = RVA_INVALID; + /** + * @brief metadata lines attached to instruction have the same offset as the instruction itself, + * this keeps track of which metdata line the cursor was at inside the offset block + */ + int selectionAnchorSubIndex = 0; + int selectionAnchorChar = 0; + RVA selectionPosRVA = RVA_INVALID; + /** + * @brief same use-case as @ref selectionAnchorSubIndex but for cursor position instead if + * anchor + */ + int selectionPosSubIndex = 0; + int selectionPosChar = 0; + QList breakpoints; + /** + * @brief Set whenever breakpoints have been updated or the screen has been manually refreshed + */ + bool breakpointsDirty = true; void setupFonts(); void setupColors(); @@ -110,9 +155,53 @@ private: void connectCursorPositionChanged(bool disconnect); - void moveCursorRelative(bool up, bool page); + void moveCursorRelative(QTextCursor::MoveOperation op, bool page); void jumpToOffsetUnderCursor(const QTextCursor &); + + /** + * @brief Visually highlights the text on screen between the selection start (anchor) and the + * current cursor position + */ + void updateSelection(); + void updateContextMenuSelection(bool hasSelection); + + /** + * @brief Finds the visual line number in the current view for a specific offset + * @param offset The memory address (RVA) to look for + * @param offsetSubIndex The specific sub-line to target if the instructions has metadata + * attached to it + * @return The index of the line, or -1 if not found + * + * @see DisassemblyHelper::getIndexInOffsetGroup() + */ + int getLineIndex(RVA offset, int offsetSubIndex) const; + /** + * @brief Refreshes the background colors in the disassembly view. + * It applies highlights to both the line currently under the user's cursor and the Program + * Counter (PC) line. + */ + void updateLineHighlights(); + /** + * @brief Clears the current text selection so nothing is highlighted as selected + */ + void invalidateCursorSelection(); + + /** + * @brief Metadata lines attached to an instruction have the same offset saved with it as the + * instruction, This function returns the index of the current line within that offset + * group/block + * * e.g: + * * @code + * ; a comment + * ; another comment <------ assume cursor is here + * ; void func1() + * 0x1000 mov rax, rax + * @endcode + * + * Then the returned index is "1" + */ + int getIndexInOffsetGroup(const QTextCursor &cursor) const; }; class DisassemblyScrollArea : public QAbstractScrollArea @@ -142,23 +231,30 @@ class DisassemblyTextEdit : public QPlainTextEdit Q_OBJECT public: - explicit DisassemblyTextEdit(QWidget *parent = nullptr) - : QPlainTextEdit(parent), lockScroll(false) - { - } + explicit DisassemblyTextEdit(DisassemblyWidget *disasmWidget = nullptr); void setLockScroll(bool lock) { this->lockScroll = lock; } qreal textOffset() const; + void setCursorVisible(bool visible); + protected: bool viewportEvent(QEvent *event) override; void scrollContentsBy(int dx, int dy) override; void keyPressEvent(QKeyEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void paintEvent(QPaintEvent *event) override; + + void mouseMoveEvent(QMouseEvent *event) override; private: bool lockScroll; + QTimer *blinkTimer; + bool cursorVisible = true; + QColor cursorColor; + + DisassemblyWidget *disasmWidget = nullptr; }; /** From 26fa614d018b88ac8d80ba05e38d7a6ecf508bab Mon Sep 17 00:00:00 2001 From: XU Pengfei Date: Mon, 27 Jul 2026 23:32:16 +0900 Subject: [PATCH 5/6] macos_sign.sh: shrink signed DMG before conversion (#3649) Without the final shrinking pass, diskutil verifyVolume errors with "Invalid number of allocation blocks", which causes issues for non-interactive mounting in package managers. --- scripts/macos_sign.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/macos_sign.sh b/scripts/macos_sign.sh index 9d0b0c0c..c9f23a40 100755 --- a/scripts/macos_sign.sh +++ b/scripts/macos_sign.sh @@ -108,9 +108,17 @@ resign_dmg() { notarize_bundle Cutter-rw/Cutter.app unmount trap - EXIT + # Remove temporary signing space and restore HFS+ volume consistency. + ee hdiutil resize -size min Cutter-rw.dmg OUTPUT="${1%.*}-signed.dmg" echo_step "Creating final read-only ${OUTPUT}" ee hdiutil convert -format UDZO -o "${OUTPUT}" Cutter-rw.dmg + echo_step "Verifying filesystem in ${OUTPUT}" + ee hdiutil attach "${OUTPUT}" -readonly -nobrowse -noautofsck -mountpoint Cutter-rw + trap unmount EXIT + ee diskutil verifyVolume Cutter-rw + unmount + trap - EXIT } case "$1" in From 4cb6ff6f989ff904e432c096a9b82a68a786b9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=A4rkl?= Date: Mon, 3 Aug 2026 16:25:15 +0200 Subject: [PATCH 6/6] Update rizin for string set config vars (#3653) Changed in rizin fa2db74f86c3432c7b68a4f1913a72722b4ac6ac: Config vars storing multiple strings do not use a list anymore but a set as the strings are meant to be unique. --- cmake/BundledRizin.cmake | 2 +- rizin | 2 +- src/core/Cutter.cpp | 64 +++++++------------ src/core/Cutter.h | 6 +- src/core/CutterDescriptions.h | 8 +-- src/core/RizinCpp.cpp | 11 ++++ src/core/RizinCpp.h | 8 ++- .../preferences/RizinConfigOptionsWidget.cpp | 14 ++-- 8 files changed, 57 insertions(+), 58 deletions(-) diff --git a/cmake/BundledRizin.cmake b/cmake/BundledRizin.cmake index b89399b4..dab0a44b 100644 --- a/cmake/BundledRizin.cmake +++ b/cmake/BundledRizin.cmake @@ -53,7 +53,7 @@ endif() # TODO: This version number should be fetched automatically # instead of being hardcoded. -set (Rizin_VERSION 0.9) +set (Rizin_VERSION 0.10) set (RZ_LIBS rz_core rz_config rz_cons rz_io rz_util rz_flag rz_mark rz_arch rz_debug rz_hash rz_bin rz_lang rz_il rz_egg rz_reg rz_search rz_syscall diff --git a/rizin b/rizin index da228d11..136a337e 160000 --- a/rizin +++ b/rizin @@ -1 +1 @@ -Subproject commit da228d11cfc865b06442bd66b192caeb6e5ff556 +Subproject commit 136a337e1c40236723a58ccca6549aa577e8aaa5 diff --git a/src/core/Cutter.cpp b/src/core/Cutter.cpp index 10178b86..425c3ac8 100644 --- a/src/core/Cutter.cpp +++ b/src/core/Cutter.cpp @@ -1187,19 +1187,15 @@ void CutterCore::setConfig(const char *k, const RzInterval &itv) void CutterCore::setConfig(const char *k, const QStringList &list) { CORE_LOCK(); - RzList *rzList = rz_list_newf(free); - if (!rzList) { + RzSetS *set = rz_set_s_new(HT_STR_DUP); + if (!set) { return; } - for (const QString &str : list) { - char *dupStr = strdup(str.toUtf8().constData()); - if (dupStr) { - rz_list_append(rzList, dupStr); - } + rz_set_s_add(set, str.toUtf8().constData()); } - - rz_config_set_list(core->config, k, rzList); + rz_config_set_set(core->config, k, set); + rz_set_s_free(set); } int CutterCore::getConfigi(const char *k) @@ -1226,16 +1222,12 @@ RzInterval CutterCore::getConfigItv(const char *k) return rz_config_get_interval(core->config, k); } -QStringList CutterCore::getConfigList(const char *k) +QSet CutterCore::getConfigSet(const char *k) { CORE_LOCK(); - - QStringList res; - RzList *list = rz_config_get_list(core->config, k); - for (const auto *s : CutterRzList(list)) { - res << QString::fromUtf8(s); - } - rz_list_free(list); + RzSetS *set = rz_config_get_set(core->config, k); + QSet res = convertRzSetS(set); + rz_set_s_free(set); return res; } @@ -1295,18 +1287,18 @@ QString CutterCore::getConfig(const char *k) return { rz_config_get(core->config, k) }; } -QStringList CutterCore::getConfigOptions(const char *k) +QSet CutterCore::getConfigOptions(const char *k) { CORE_LOCK(); const RzConfigNode *node = rz_config_node_get(core->config, k); if (!(node && node->options)) { return {}; } - QStringList list; - for (const auto &s : CutterRzList(node->options)) { - list << s; + QSet res; + for (auto it = CutterRzIter(rz_set_s_as_iter(node->options)); it; ++it) { + res << QString::fromUtf8(*it); } - return list; + return res; } void CutterCore::setConfig(const char *k, const QVariant &v) @@ -4154,13 +4146,11 @@ QList CutterCore::getAllEvaluableVars() var.type = EvaluableVarDescription::Interval; const RzInterval itv = rz_config_var_get_interval(v); value = QVariant::fromValue(itv); - } else if (RZ_CONFIG_VAR_IS_TYPE(flags, RZ_CONFIG_VAR_TYPE_LIST)) { - var.type = EvaluableVarDescription::List; - const RzList *list = rz_config_var_get_list(v); - QStringList stringList; - for (const auto *c : CutterRzList(list)) { - stringList << QString(c); - } + } else if (RZ_CONFIG_VAR_IS_TYPE(flags, RZ_CONFIG_VAR_TYPE_SET)) { + var.type = EvaluableVarDescription::Set; + RzSetS *set = rz_config_var_get_set(v); + value = QVariant::fromValue(convertRzSetS(set)); + rz_set_s_free(set); } if (value.isNull()) { @@ -4168,13 +4158,9 @@ QList CutterCore::getAllEvaluableVars() } var.value = value; - const RzList *optionsList = rz_config_var_get_options(v); - if (optionsList) { - RzListIter *iter; - char *option; - CutterRzListForeach (optionsList, iter, char, option) { - var.options << QString::fromUtf8(option); - } + const RzSetS *optionsSet = rz_config_var_get_options(v); + if (optionsSet) { + var.options = convertRzSetS(optionsSet); } } else { const RzConfigNode *node = &entry->node; @@ -4189,10 +4175,8 @@ QList CutterCore::getAllEvaluableVars() var.value = QString::fromUtf8(rz_config_entry_get_as_string(entry)); - RzListIter *iter; - char *option; - CutterRzListForeach (node->options, iter, char, option) { - var.options << QString::fromUtf8(option); + if (node->options) { + var.options = convertRzSetS(node->options); } } diff --git a/src/core/Cutter.h b/src/core/Cutter.h index f57f2eab..423c6032 100644 --- a/src/core/Cutter.h +++ b/src/core/Cutter.h @@ -495,12 +495,12 @@ public: bool getConfigb(const QString &k) { return getConfigb(k.toUtf8().constData()); } RzInterval getConfigItv(const char *k); RzInterval getConfigItv(const QString &k) { return getConfigItv(k.toUtf8().constData()); } - QStringList getConfigList(const char *k); - QStringList getConfigList(const QString &k) { return getConfigList(k.toUtf8().constData()); } + QSet getConfigSet(const char *k); + QSet getConfigSet(const QString &k) { return getConfigSet(k.toUtf8().constData()); } QString getConfig(const char *k); QString getConfig(const QString &k) { return getConfig(k.toUtf8().constData()); } QString getConfigDescription(const char *k); - QStringList getConfigOptions(const char *k); + QSet getConfigOptions(const char *k); QStringList getColorThemes(); QHash getTheme(); QStringList getThemeKeys(); diff --git a/src/core/CutterDescriptions.h b/src/core/CutterDescriptions.h index 0f23398b..0eea0278 100644 --- a/src/core/CutterDescriptions.h +++ b/src/core/CutterDescriptions.h @@ -10,8 +10,8 @@ #include #include #include +#include #include -#include #include struct FunctionDescription @@ -448,11 +448,11 @@ struct EvaluableVarDescription QString description; bool readOnly; - enum Type : ut8 { Bool = 0, Int, String, Interval, List }; + enum Type : ut8 { Bool = 0, Int, String, Interval, Set }; Type type; - QVariant value; ///< Can be either QString, QStringList or RzInterval depending on type - QList options; + QVariant value; ///< Can be either QString, QSet or RzInterval depending on type + QSet options; }; Q_DECLARE_METATYPE(FunctionDescription) diff --git a/src/core/RizinCpp.cpp b/src/core/RizinCpp.cpp index 594dfff6..559a7c8f 100644 --- a/src/core/RizinCpp.cpp +++ b/src/core/RizinCpp.cpp @@ -1 +1,12 @@ #include "RizinCpp.h" + +#include + +QSet convertRzSetS(const RzSetS *set) +{ + QSet res; + for (auto it = CutterRzIter(rz_set_s_as_iter(set)); it; ++it) { + res << QString::fromUtf8(*it); + } + return res; +} diff --git a/src/core/RizinCpp.h b/src/core/RizinCpp.h index b1a39dbd..407ba221 100644 --- a/src/core/RizinCpp.h +++ b/src/core/RizinCpp.h @@ -187,9 +187,9 @@ public: return *this; } operator bool() { return rzIter && rzIter->cur; } - T &operator*() { return *reinterpret_cast(rzIter->cur); } - T *get() { return reinterpret_cast(rzIter->cur); } - T *operator->() { return reinterpret_cast(rzIter->cur); } + T &operator*() { return *reinterpret_cast(rzIter->cur); } + T *get() { return reinterpret_cast(rzIter->cur); } + T *operator->() { return reinterpret_cast(rzIter->cur); } }; #define CutterHtDef(xx, XX, K, VB) \ @@ -219,4 +219,6 @@ public: CutterHtDef(sp, SP, const char *, void *); +QSet convertRzSetS(const RzSetS *set); + #endif // RIZINCPP_H diff --git a/src/dialogs/preferences/RizinConfigOptionsWidget.cpp b/src/dialogs/preferences/RizinConfigOptionsWidget.cpp index 0f625b55..38db0430 100644 --- a/src/dialogs/preferences/RizinConfigOptionsWidget.cpp +++ b/src/dialogs/preferences/RizinConfigOptionsWidget.cpp @@ -125,7 +125,7 @@ QVariant RizinConfigOptionsModel::data(const QModelIndex &index, int role) const const auto itv = evalVar.value.value(); return QString("%1+%2").arg(rzAddressString(itv.addr), rzSizeString(itv.size)); } - if (evalVar.type == EvaluableVarDescription::List) { + if (evalVar.type == EvaluableVarDescription::Set) { const QStringList list = evalVar.value.toStringList(); if (list.isEmpty()) { return QString(); @@ -344,14 +344,16 @@ QWidget *RizinConfigOptionsDelegate::createEditor(QWidget *parent, if (evalVar.type == EvaluableVarDescription::Bool || evalVar.type == EvaluableVarDescription::Interval - || evalVar.type == EvaluableVarDescription::List) { + || evalVar.type == EvaluableVarDescription::Set) { // handled in lambda connected to ui->treeView::doubleClicked return nullptr; } if (!evalVar.options.isEmpty()) { auto *comboBox = new QComboBox(parent); - for (const auto &opt : evalVar.options) { + QStringList opts = evalVar.options.values(); + opts.sort(); + for (const auto &opt : opts) { comboBox->addItem(opt); } return comboBox; @@ -486,7 +488,7 @@ RizinConfigOptionsWidget::RizinConfigOptionsWidget(PreferencesDialog *parent) return; } - if (evalVar.type == EvaluableVarDescription::List) { + if (evalVar.type == EvaluableVarDescription::Set) { StringListDialog dialog(evalVar.value.toStringList(), this); dialog.setWindowTitle(tr("Edit List for %1").arg(evalVar.name)); @@ -596,8 +598,8 @@ void RizinConfigOptionsWidget::handleConfigOptionChanged(const QModelIndex &topL if (!originalValues.contains(evalVar.name)) { if (evalVar.type == EvaluableVarDescription::Interval) { originalValues[evalVar.name] = QVariant::fromValue(Core()->getConfigItv(evalVar.name)); - } else if (evalVar.type == EvaluableVarDescription::List) { - originalValues[evalVar.name] = QVariant::fromValue(Core()->getConfigList(evalVar.name)); + } else if (evalVar.type == EvaluableVarDescription::Set) { + originalValues[evalVar.name] = QVariant::fromValue(Core()->getConfigSet(evalVar.name)); } else { originalValues[evalVar.name] = Core()->getConfig(evalVar.name); }