Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion PyMemoryEditor/app/cheat_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 14 additions & 11 deletions PyMemoryEditor/app/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/results_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()})
Expand Down
6 changes: 6 additions & 0 deletions PyMemoryEditor/app/scan_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions PyMemoryEditor/app/scanner_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
Loading