From 7d09c2b175d3c295ba3248f76a404131f2a5f9f1 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 31 May 2026 22:30:17 -0300 Subject: [PATCH] feat: keyboard and mouse shortcuts for scanner workflow - Results table: double-click promotes a row to the cheat table, and Enter/Return promotes the selected rows, matching the other tables. - Results table: floor/stretch column resize modes and an empty-state hint so a blank table reads as ready rather than broken. - Cheat table: Delete key removes selected rows (widget-scoped so it doesn't interfere with inline editing); double-click on the Address or Type column opens Edit Selected. - Scanner panel: focus the Value field on first show and run the valid scan (First/Next) when Enter is pressed in a value field. - Status bar: report final tallies on scan complete and refine, and emit a final tally from the refine worker so counts never go stale. --- PyMemoryEditor/app/cheat_table.py | 22 +++++++++- PyMemoryEditor/app/main_window.py | 25 ++++++----- PyMemoryEditor/app/results_view.py | 68 ++++++++++++++++++++++++++--- PyMemoryEditor/app/scan_worker.py | 6 +++ PyMemoryEditor/app/scanner_panel.py | 25 +++++++++++ 5 files changed, 129 insertions(+), 17 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index f245d50..1cdff35 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -21,7 +21,7 @@ from typing import Dict, List, Optional, Tuple from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QAction +from PySide6.QtGui import QAction, QKeySequence, QShortcut from PySide6.QtWidgets import ( QAbstractItemView, QCheckBox, @@ -172,10 +172,19 @@ def _build_ui(self) -> None: self.COL_VALUE, QHeaderView.Stretch ) self._table.cellChanged.connect(self._on_cell_changed) + self._table.doubleClicked.connect(self._on_double_clicked) self._table.setContextMenuPolicy(Qt.CustomContextMenu) self._table.customContextMenuRequested.connect(self._show_context_menu) layout.addWidget(self._table, 1) + # Delete removes the selected rows — the standard gesture, previously + # only reachable through the context menu. Scoped to the table widget + # (WidgetShortcut) so Delete still edits text while a cell is in + # inline-edit mode (the editor child, not the table, has focus then). + delete_shortcut = QShortcut(QKeySequence(QKeySequence.Delete), self._table) + delete_shortcut.setContext(Qt.WidgetShortcut) + delete_shortcut.activated.connect(self._on_remove_selected) + def add_entry(self, entry: CheatEntry) -> None: # If the address already exists, just refresh its description/type. for existing in self._entries: @@ -378,6 +387,17 @@ def _editing_row(self) -> int: index = self._table.currentIndex() return index.row() if index.isValid() else -1 + def _on_double_clicked(self, index) -> None: + """Open Edit Selected when a row's non-editable cell is double-clicked. + + Description and Value stay inline-editable on double-click (the Value + cell's whole point is "double-click to write a new value"), so we only + hook the Address and Type columns — otherwise dead to double-click — + to the bulk edit dialog. + """ + if index.isValid() and index.column() in (self.COL_ADDRESS, self.COL_TYPE): + self._on_edit_selected() + def _on_add_manually(self) -> None: entry = prompt_for_manual_entry(self) if entry is not None: diff --git a/PyMemoryEditor/app/main_window.py b/PyMemoryEditor/app/main_window.py index fff6b97..9e4b5f1 100644 --- a/PyMemoryEditor/app/main_window.py +++ b/PyMemoryEditor/app/main_window.py @@ -485,21 +485,24 @@ def _on_first_chunk(self, chunk) -> None: self._results_label.setText(f"{self._results_model.count():,} addresses found.") def _on_first_scan_done(self, count: int) -> None: - self._results_label.setText(f"{self._results_model.count():,} addresses found.") - if count == 0: - self._scanner.set_has_results(False) - else: - self._scanner.set_has_results(True) + total = self._results_model.count() + self._results_label.setText(f"{total:,} addresses found.") + self._status.showMessage(f"Scan complete — {total:,} addresses found.") + self._scanner.set_has_results(count != 0) def _on_refine_done(self, kept: int) -> None: - self._results_label.setText(f"{self._results_model.count():,} addresses left.") - self._scanner.set_has_results(self._results_model.count() > 0) + total = self._results_model.count() + self._results_label.setText(f"{total:,} addresses left.") + self._status.showMessage(f"Scan refined — {total:,} addresses left.") + self._scanner.set_has_results(total > 0) def _on_refresh_done(self, _kept: int) -> None: - self._results_label.setText( - f"{self._results_model.count():,} addresses found." - ) - self._scanner.set_has_results(self._results_model.count() > 0) + total = self._results_model.count() + self._results_label.setText(f"{total:,} addresses found.") + # Leave the status bar showing the worker's final + # "Checked {seen}/{total}, kept {kept}…" tally rather than overwriting + # it — it's emitted just before finished_ok, so it's already on screen. + self._scanner.set_has_results(total > 0) def _on_worker_error(self, message: str) -> None: _LOG.error("Scan worker error: %s", message) diff --git a/PyMemoryEditor/app/results_view.py b/PyMemoryEditor/app/results_view.py index 3aa01bc..0c802e7 100644 --- a/PyMemoryEditor/app/results_view.py +++ b/PyMemoryEditor/app/results_view.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional, Tuple from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal -from PySide6.QtGui import QAction, QColor +from PySide6.QtGui import QAction, QColor, QPainter, QPalette from PySide6.QtWidgets import ( QAbstractItemView, QHeaderView, @@ -200,12 +200,70 @@ def __init__(self, parent: Optional[QWidget] = None): self.setSortingEnabled(False) # streaming inserts → custom sorting is expensive self.setAlternatingRowColors(True) self.verticalHeader().setVisible(False) - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setSectionResizeMode( - COL_ADDRESS, QHeaderView.ResizeToContents - ) + self.horizontalHeader().setStretchLastSection(False) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) + # Double-click promotes the row to the cheat table — the same quick + # gesture the Memory Map / Modules / Pointer Scan tables already use. + self.doubleClicked.connect(self._on_double_clicked) + + def _on_double_clicked(self, index) -> None: + if not index.isValid(): + return + model: ResultsModel = self.model() + address = model.address_at(index.row()) + if address is not None: + self.promote_to_cheat_table.emit([address]) + + def keyPressEvent(self, event) -> None: + # Enter/Return promotes the selected rows to the cheat table — the + # keyboard equivalent of double-clicking or the context-menu action, + # so the table is fully usable without the mouse. + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + model: ResultsModel = self.model() + if model is not None: + rows = sorted({idx.row() for idx in self.selectedIndexes()}) + addresses = [ + model.address_at(r) + for r in rows + if model.address_at(r) is not None + ] + if addresses: + self.promote_to_cheat_table.emit(addresses) + return + super().keyPressEvent(event) + + def setModel(self, model) -> None: + super().setModel(model) + # Per-section resize modes only stick once a model is attached (the + # header rebuilds its sections on setModel and resets them otherwise). + # Address hugs its fixed-width hex content; Value and Previous split the + # remaining width evenly so neither — least of all the less-important + # Previous column — swallows the whole row. + header = self.horizontalHeader() + # Floor every section's width so Address doesn't collapse to a few + # characters for short addresses; ResizeToContents still grows it for + # long 64-bit ones. Value/Previous are Stretch and always exceed this. + header.setMinimumSectionSize(120) + header.setSectionResizeMode(COL_ADDRESS, QHeaderView.ResizeToContents) + header.setSectionResizeMode(COL_VALUE, QHeaderView.Stretch) + header.setSectionResizeMode(COL_PREVIOUS, QHeaderView.Stretch) + + def paintEvent(self, event) -> None: + super().paintEvent(event) + model = self.model() + if model is not None and model.rowCount() > 0: + return + # Empty-state guidance painted over the viewport so the large blank + # table reads as "ready and waiting" rather than broken. + painter = QPainter(self.viewport()) + painter.setPen(self.palette().color(QPalette.PlaceholderText)) + painter.drawText( + self.viewport().rect(), + int(Qt.AlignCenter), + "Found addresses will appear here.", + ) + painter.end() def _show_context_menu(self, pos) -> None: rows = sorted({idx.row() for idx in self.selectedIndexes()}) diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 3efaa37..bf3ad35 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -287,6 +287,12 @@ def run(self) -> None: if chunk: self.chunk_ready.emit(chunk) + # Emit a final tally so the status always ends on the complete + # numbers — the in-loop status only fires every UI_REFRESH_STEP + # rows, so a small set (or the last partial chunk) would otherwise + # leave a stale "Updating values…" / mid-progress count behind. + if not self._cancelled: + self.status.emit(f"Checked {seen:,}/{total:,}, kept {kept:,}…") self.progress.emit(100.0) self.finished_ok.emit(kept) except Exception as exc: # noqa: BLE001 — surface every backend error to the UI diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 610e37d..a5f3415 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -78,9 +78,32 @@ def __init__(self, parent=None): super().__init__(parent) self._has_results = False self._busy = False + self._initial_focus_done = False self._build_ui() self._refresh_buttons() + def showEvent(self, event) -> None: + super().showEvent(event) + # Land the cursor in the Value field the first time the panel appears + # so the user can type a target value and press Enter without reaching + # for the mouse. Guarded so restoring/raising the window later doesn't + # yank focus away from wherever the user put it. + if not self._initial_focus_done: + self._initial_focus_done = True + self._value_edit.setFocus() + + def _on_value_submitted(self) -> None: + """Enter in a value field runs the scan that's currently valid. + + Mirrors the buttons' enabled state: First Scan before any results + exist, Next Scan once they do (and neither in pattern mode / while a + scan is running, where the buttons are disabled). + """ + if self._first_scan_btn.isEnabled(): + self._on_first_scan() + elif self._next_scan_btn.isEnabled(): + self._on_next_scan() + def _build_ui(self) -> None: layout = QVBoxLayout(self) # Small right inset so the group boxes don't sit flush against the @@ -95,10 +118,12 @@ def _build_ui(self) -> None: self._value_edit = QLineEdit() self._value_edit.setPlaceholderText("e.g. 100 or 0x64 or Hello") + self._value_edit.returnPressed.connect(self._on_value_submitted) value_form.addRow("Value:", self._value_edit) self._second_value_edit = QLineEdit() self._second_value_edit.setPlaceholderText("Upper bound (for ranges only)") + self._second_value_edit.returnPressed.connect(self._on_value_submitted) self._second_value_label = QLabel("Up to:") value_form.addRow(self._second_value_label, self._second_value_edit) self._second_value_edit.hide()