PyMemoryEditor 3.0.2__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. PyMemoryEditor/__init__.py +1 -1
  2. PyMemoryEditor/app/_widgets.py +106 -27
  3. PyMemoryEditor/app/application.py +72 -12
  4. PyMemoryEditor/app/cheat_table.py +95 -23
  5. PyMemoryEditor/app/log_console_dialog.py +2 -2
  6. PyMemoryEditor/app/main_window.py +79 -24
  7. PyMemoryEditor/app/memory_map_dialog.py +4 -14
  8. PyMemoryEditor/app/memory_viewer_dialog.py +14 -15
  9. PyMemoryEditor/app/modules_dialog.py +2 -3
  10. PyMemoryEditor/app/open_process_dialog.py +2 -14
  11. PyMemoryEditor/app/pointer_chain_dialog.py +18 -16
  12. PyMemoryEditor/app/pointer_scan_dialog.py +24 -25
  13. PyMemoryEditor/app/results_view.py +89 -15
  14. PyMemoryEditor/app/scan_worker.py +2 -1
  15. PyMemoryEditor/linux/functions.py +177 -10
  16. PyMemoryEditor/linux/process.py +100 -12
  17. PyMemoryEditor/macos/functions.py +416 -166
  18. PyMemoryEditor/macos/libsystem.py +15 -0
  19. PyMemoryEditor/macos/process.py +8 -0
  20. PyMemoryEditor/macos/types.py +52 -1
  21. PyMemoryEditor/mcp/__init__.py +4 -2
  22. PyMemoryEditor/mcp/config.py +58 -34
  23. PyMemoryEditor/mcp/policy.py +108 -16
  24. PyMemoryEditor/mcp/server.py +48 -22
  25. PyMemoryEditor/mcp/session.py +227 -58
  26. PyMemoryEditor/mcp/toolset.py +435 -233
  27. PyMemoryEditor/process/abstract.py +136 -33
  28. PyMemoryEditor/process/pointer_scan.py +130 -19
  29. PyMemoryEditor/process/region.py +1 -4
  30. PyMemoryEditor/process/remote_pointer.py +11 -3
  31. PyMemoryEditor/process/scanning.py +241 -122
  32. PyMemoryEditor/process/thread_info.py +6 -4
  33. PyMemoryEditor/util/convert.py +51 -50
  34. PyMemoryEditor/util/pattern.py +38 -6
  35. PyMemoryEditor/util/scan.py +211 -80
  36. PyMemoryEditor/util/scan_numpy.py +50 -26
  37. PyMemoryEditor/win32/functions.py +280 -87
  38. PyMemoryEditor/win32/process.py +70 -12
  39. PyMemoryEditor/win32/types.py +23 -16
  40. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/METADATA +3 -1
  41. pymemoryeditor-3.2.0.dist-info/RECORD +73 -0
  42. pymemoryeditor-3.0.2.dist-info/RECORD +0 -73
  43. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/WHEEL +0 -0
  44. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/entry_points.txt +0 -0
  45. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,7 +8,7 @@ Supported platforms: Windows, Linux and macOS (32-bit and 64-bit).
8
8
  """
9
9
 
10
10
  __author__ = "Jean Loui Bernard Silva de Jesus"
11
- __version__ = "3.0.2"
11
+ __version__ = "3.2.0"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -6,9 +6,10 @@ Centralises tiny helpers (numeric sort items, hex address parsing) that
6
6
  previously appeared duplicated across several dialog modules.
7
7
  """
8
8
 
9
+ import threading
9
10
  from typing import Any, Callable, Iterable, List, Optional, Tuple
10
11
 
11
- from PySide6.QtCore import QObject, Qt, QThread
12
+ from PySide6.QtCore import QCoreApplication, QObject, Qt, QThread, QTimer
12
13
  from PySide6.QtGui import QStandardItem
13
14
 
14
15
 
@@ -84,6 +85,56 @@ def _reap_detached_worker(worker: QThread) -> None:
84
85
  worker.deleteLater()
85
86
 
86
87
 
88
+ def detached_workers_running() -> bool:
89
+ """Whether any worker that outlived its owner is still running."""
90
+ return any(worker.isRunning() for worker in _DETACHED_WORKERS)
91
+
92
+
93
+ def run_after_detached_workers(callback: Callable[[], None]) -> None:
94
+ """Run ``callback`` once every currently detached worker has finished.
95
+
96
+ A detached worker can still be inside a backend syscall on the process it
97
+ was given, so releasing that process (``close()``) must wait for it: on
98
+ Windows the handle value can be recycled for an unrelated object the moment
99
+ it is closed, and a late write from the cheat poller would land there.
100
+ With no worker still running, ``callback`` runs immediately.
101
+ """
102
+ pending = [worker for worker in _DETACHED_WORKERS if worker.isRunning()]
103
+ if not pending:
104
+ callback()
105
+ return
106
+
107
+ remaining = set(pending)
108
+ done = False
109
+ # `finished` can be delivered from the worker's own thread while the
110
+ # isFinished() sweep below runs on this one: guard the bookkeeping so the
111
+ # callback (a handle close) runs exactly once.
112
+ guard = threading.Lock()
113
+ # Whichever thread notices the last worker finishing, the callback is
114
+ # posted to the GUI thread (the application object lives there), so it
115
+ # never runs concurrently with the GUI setting up the next process.
116
+ gui = QCoreApplication.instance()
117
+
118
+ def _on_finished(worker: QThread) -> None:
119
+ nonlocal done
120
+ with guard:
121
+ remaining.discard(worker)
122
+ if remaining or done:
123
+ return
124
+ done = True
125
+ if gui is None:
126
+ callback()
127
+ else:
128
+ QTimer.singleShot(0, gui, callback)
129
+
130
+ for worker in pending:
131
+ worker.finished.connect(lambda w=worker: _on_finished(w))
132
+ # Same race as detach_worker: one may have finished before its connect.
133
+ for worker in pending:
134
+ if worker.isFinished():
135
+ _on_finished(worker)
136
+
137
+
87
138
  class TearsDownOnClose:
88
139
  """Mixin that runs a dialog's teardown on *every* way out, not just close.
89
140
 
@@ -166,45 +217,76 @@ class NumericItem(QStandardItem):
166
217
  return self.text() < other.text()
167
218
 
168
219
 
220
+ _HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
221
+
222
+
223
+ def _parse_hex_digits(text: str) -> Optional[int]:
224
+ """``text`` as an unsigned hex number, or None. Accepts a ``0x`` prefix."""
225
+ cleaned = text.strip()
226
+ if cleaned[:2].lower() == "0x":
227
+ cleaned = cleaned[2:]
228
+ # Explicit digit check rather than relying on int(): int(x, 16) also takes
229
+ # "1_0" (underscore separators) and leading sign/whitespace, none of which
230
+ # is a hex address a user meant to type.
231
+ if not cleaned or not _HEX_DIGITS.issuperset(cleaned):
232
+ return None
233
+ return int(cleaned, 16)
234
+
235
+
169
236
  def parse_hex_address(text: str) -> Optional[int]:
170
237
  """Parse a hex address string (with or without 0x prefix) into an int.
171
238
 
172
- Returns None on any parse error. Whitespace is tolerated.
239
+ Addresses are unsigned: a leading sign makes the string invalid. Returns
240
+ None on any parse error. Surrounding whitespace is tolerated.
173
241
  """
174
242
  if not text:
175
243
  return None
176
- cleaned = text.strip()
177
- if not cleaned:
244
+ return _parse_hex_digits(text)
245
+
246
+
247
+ def parse_hex_offset(text: str) -> Optional[int]:
248
+ """Parse a hex pointer-chain offset, which unlike an address may be negative.
249
+
250
+ ``"-0x10"`` → -16, ``"10"`` → 16. Returns None on any parse error.
251
+ """
252
+ if not text:
178
253
  return None
179
- if cleaned.lower().startswith("0x"):
180
- cleaned = cleaned[2:]
181
- try:
182
- return int(cleaned, 16)
183
- except (TypeError, ValueError):
254
+ cleaned = text.strip()
255
+ negative = cleaned.startswith("-")
256
+ if negative or cleaned.startswith("+"):
257
+ cleaned = cleaned[1:]
258
+ parsed = _parse_hex_digits(cleaned)
259
+ if parsed is None:
184
260
  return None
261
+ return -parsed if negative else parsed
262
+
263
+
264
+ def format_size(size: int) -> str:
265
+ """Human-readable byte count (``1536`` → ``"1.5 KB"``), binary units."""
266
+ units = ["B", "KB", "MB", "GB", "TB"]
267
+ s = float(size)
268
+ for unit in units:
269
+ if s < 1024 or unit == units[-1]:
270
+ return f"{s:,.1f} {unit}" if unit != "B" else f"{int(s):,} B"
271
+ s /= 1024
272
+ return f"{size:,} B"
185
273
 
186
274
 
187
275
  def parse_offsets(texts: Iterable[str]) -> Optional[List[int]]:
188
276
  """Parse pointer-chain offset tokens (in order) from raw field strings.
189
277
 
190
278
  Empty tokens are skipped; every remaining token is read as hex (with or
191
- without ``0x``). Returns ``None`` if any non-empty token can't be parsed —
192
- the caller treats that as "invalid chain, do nothing". Pure (no Qt) so it
193
- can be unit-tested directly.
279
+ without ``0x``, optionally negative). Returns ``None`` if any non-empty
280
+ token can't be parsed — the caller treats that as "invalid chain, do
281
+ nothing". Pure (no Qt) so it can be unit-tested directly.
194
282
  """
195
283
  offsets: List[int] = []
196
284
  for text in texts:
197
285
  if not text:
198
286
  continue
199
- parsed = parse_hex_address(text)
287
+ parsed = parse_hex_offset(text)
200
288
  if parsed is None:
201
- # parse_hex_address handles the 0x form; fall back to a plain hex
202
- # int for ambiguous tokens like "10" (the dialog treats offsets as
203
- # hex throughout).
204
- try:
205
- parsed = int(text, 16)
206
- except ValueError:
207
- return None
289
+ return None
208
290
  offsets.append(parsed)
209
291
  return offsets
210
292
 
@@ -230,13 +312,10 @@ def resolve_base_address(
230
312
  module_name = name_part.strip().strip('"').strip("'").strip()
231
313
  offset = parse_hex_address(offset_part)
232
314
  if offset is None:
233
- try:
234
- offset = int(offset_part.strip(), 16)
235
- except ValueError:
236
- return None, (
237
- "The offset after '+' must be hex "
238
- '(e.g. "game.exe"+0x10F4F4).'
239
- )
315
+ return None, (
316
+ "The offset after '+' must be hex "
317
+ '(e.g. "game.exe"+0x10F4F4).'
318
+ )
240
319
  module_base = module_lookup(module_name)
241
320
  if module_base is None:
242
321
  return None, (
@@ -5,6 +5,7 @@ Entry point for the PyMemoryEditor App.
5
5
  A Cheat-Engine-inspired memory scanner built on PySide6 (Qt for Python),
6
6
  working on Windows, Linux and macOS.
7
7
  """
8
+ import logging
8
9
  import sys
9
10
  import signal
10
11
  import contextlib
@@ -266,11 +267,6 @@ def apply_theme(app, theme_id: str = DEFAULT_THEME_ID) -> None:
266
267
  )
267
268
 
268
269
 
269
- def apply_dark_theme(app) -> None:
270
- """Backward-compatible shim that applies the default dark theme."""
271
- apply_theme(app, DEFAULT_THEME_ID)
272
-
273
-
274
270
  STYLE_SHEET = """
275
271
  QToolTip {
276
272
  color: %(text)s;
@@ -484,10 +480,66 @@ def _scoped_signal_handler(signalnum, handler):
484
480
  signal.signal(signalnum, previous_handler)
485
481
 
486
482
 
483
+ _LOG = logging.getLogger(__name__)
484
+
485
+
486
+ def _qt_excepthook(exc_type, exc_value, exc_tb) -> None:
487
+ """``sys.excepthook`` for the duration of the Qt event loop.
488
+
489
+ PySide6 catches an exception raised inside a slot, hands it to
490
+ ``sys.excepthook`` and carries on — with the default hook that is a
491
+ traceback on stderr the user never sees, and the action they clicked
492
+ silently does nothing. Log it (so the Log Console gets it) and show it.
493
+ KeyboardInterrupt is left to the interpreter's hook: it is the host's
494
+ signal, not an app bug.
495
+ """
496
+ if issubclass(exc_type, KeyboardInterrupt):
497
+ sys.__excepthook__(exc_type, exc_value, exc_tb)
498
+ return
499
+ _LOG.error(
500
+ "Unhandled exception in the UI", exc_info=(exc_type, exc_value, exc_tb)
501
+ )
502
+ try:
503
+ from PySide6.QtCore import QThread
504
+ from PySide6.QtWidgets import QApplication, QMessageBox
505
+
506
+ app = QApplication.instance()
507
+ # Widgets may only be created on the GUI thread. PySide6 routes an
508
+ # exception escaping a QThread.run() through this same hook, from
509
+ # that worker thread, so a dialog there would be undefined behaviour.
510
+ # The log line above is the whole report in that case.
511
+ if app is None or QThread.currentThread() != app.thread():
512
+ return
513
+ QMessageBox.critical(
514
+ None,
515
+ "Unexpected error",
516
+ "%s: %s\n\nThe full traceback is in Tools → Log Console."
517
+ % (exc_type.__name__, exc_value),
518
+ )
519
+ except Exception: # noqa: BLE001 — a hook must never raise
520
+ pass
521
+
522
+
523
+ @contextlib.contextmanager
524
+ def _scoped_excepthook(hook):
525
+ """Install ``hook`` as ``sys.excepthook`` for the block, then restore."""
526
+ previous = sys.excepthook
527
+ sys.excepthook = hook
528
+ try:
529
+ yield
530
+ finally:
531
+ sys.excepthook = previous
532
+
533
+
487
534
  def main(argv=None):
488
535
  """
489
536
  Run the app. Safe to call in-process: it installs no signal handler.
490
537
 
538
+ While the event loop runs, ``sys.excepthook`` is swapped for one that logs
539
+ and shows slot exceptions (PySide6 routes them there and continues), and
540
+ restored on the way out — the loop owns the thread for that time, so an
541
+ embedder's own hook is not observable from inside it anyway.
542
+
491
543
  ``argv`` defaults to ``sys.argv`` so packaging tools (which call
492
544
  ``main()`` with no arguments) keep working. Tests and embedders can pass
493
545
  an explicit list — previously a positional ``*args`` was accepted but
@@ -509,6 +561,7 @@ def main(argv=None):
509
561
  from PySide6.QtCore import QSettings
510
562
  from PySide6.QtWidgets import QApplication
511
563
 
564
+ from ._widgets import detached_workers_running
512
565
  from .main_window import MainWindow
513
566
  from .open_process_dialog import OpenProcessDialog
514
567
 
@@ -536,14 +589,21 @@ def main(argv=None):
536
589
  window = MainWindow(process)
537
590
  window.show()
538
591
  try:
539
- app.exec()
592
+ with _scoped_excepthook(_qt_excepthook):
593
+ app.exec()
540
594
  finally:
541
- try:
542
- # `process` is only the handle the picker opened; File → Change
543
- # Process… released that one and attached the window to another.
544
- window.process.close()
545
- except Exception:
546
- pass
595
+ # A worker that blew its join on close is detached, not stopped, and
596
+ # may still be inside a syscall on this handle; closing it now could
597
+ # hand its value to an unrelated object mid-write. The process is
598
+ # exiting, so leave it to the OS, which releases every handle then.
599
+ if not detached_workers_running():
600
+ try:
601
+ # `process` is only the handle the picker opened; File →
602
+ # Change Process… released that one and attached the window
603
+ # to another.
604
+ window.process.close()
605
+ except Exception:
606
+ pass
547
607
 
548
608
 
549
609
  def main_cli(argv=None):
@@ -56,11 +56,6 @@ from .value_types import (
56
56
  )
57
57
 
58
58
 
59
- # Re-exported for backward compatibility with callers that imported the
60
- # poll-interval constant from this module before the split.
61
- _TICK_INTERVAL_MS = TICK_INTERVAL_MS
62
-
63
-
64
59
  class CheatTable(QWidget):
65
60
  """Bottom pane: saved addresses, freezing, manual edits."""
66
61
 
@@ -80,6 +75,14 @@ class CheatTable(QWidget):
80
75
  self._suspend_signals = False
81
76
  # Before _build_ui(), which connects cellChanged — a path that reads it.
82
77
  self._poller_stopped = False
78
+ # Set while the owner knows the target is gone (see set_polling_paused):
79
+ # the poller idles on an empty snapshot and edits are refused instead
80
+ # of being queued against a dead handle.
81
+ self._paused = False
82
+ # Write failures reported by the poller since the last flush. They are
83
+ # shown in one dialog per event-loop burst, so a bulk edit whose every
84
+ # row fails (target exited) produces one message, not one per row.
85
+ self._pending_write_failures: List[Tuple[int, str]] = []
83
86
 
84
87
  self._build_ui()
85
88
 
@@ -130,6 +133,31 @@ class CheatTable(QWidget):
130
133
  self.shutdown()
131
134
  super().closeEvent(event)
132
135
 
136
+ def set_polling_paused(self, paused: bool) -> None:
137
+ """Idle the poll loop while the target is known to be gone.
138
+
139
+ The owning window's heartbeat calls this when the process exits. The
140
+ publish timer stops and the worker is handed an empty snapshot, so it
141
+ sleeps through its ticks instead of hitting a dead handle ten times a
142
+ second (and, on a recycled PID, a stranger's memory). Entries are kept:
143
+ the table still shows the last values read. Resuming re-publishes.
144
+ """
145
+ if self._poller_stopped or paused == self._paused:
146
+ return
147
+ self._paused = paused
148
+ if paused:
149
+ self._publish_timer.stop()
150
+ self._poller.update_snapshot([])
151
+ else:
152
+ self._publish_snapshot_to_worker()
153
+ self._publish_timer.start()
154
+
155
+ def is_polling_paused(self) -> bool:
156
+ return self._paused
157
+
158
+ def entry_count(self) -> int:
159
+ return len(self._entries)
160
+
133
161
  def _build_ui(self) -> None:
134
162
  layout = QVBoxLayout(self)
135
163
  # Small top inset so the toolbar buttons don't sit flush against the
@@ -208,8 +236,17 @@ class CheatTable(QWidget):
208
236
  # spotted from the table, so the floor is enforced once, at the door,
209
237
  # rather than at each of those call sites. (The AOB pattern spec is the
210
238
  # one whose declared length is 0 — the scanner derives its real width
211
- # from the pattern.)
212
- entry.length = max(1, int(entry.length))
239
+ # from the pattern.) The ceiling is the same one every other width
240
+ # field enforces (MAX_ENTRY_LENGTH); only a JSON import can carry more,
241
+ # and silently resizing its entry would alter what the user exports
242
+ # next, so that is refused -- the import loop reports and skips it.
243
+ length = int(entry.length)
244
+ if length > MAX_ENTRY_LENGTH:
245
+ raise ValueError(
246
+ "entry at 0x%X has length %d, over the %d-byte maximum"
247
+ % (entry.address, length, MAX_ENTRY_LENGTH)
248
+ )
249
+ entry.length = max(1, length)
213
250
 
214
251
  # If the address already exists, just refresh its description/type.
215
252
  for existing in self._entries:
@@ -236,7 +273,16 @@ class CheatTable(QWidget):
236
273
  length: int,
237
274
  description: str = "",
238
275
  ) -> None:
239
- """Convenience used by the scanner panel to bulk-promote rows."""
276
+ """Convenience used by the scanner panel to bulk-promote rows.
277
+
278
+ :raises ValueError: if ``length`` is over ``MAX_ENTRY_LENGTH`` -- checked
279
+ once up front so a refusal adds none of the rows rather than some.
280
+ """
281
+ if int(length) > MAX_ENTRY_LENGTH:
282
+ raise ValueError(
283
+ "length %d is over the %d-byte maximum for a cheat-table entry"
284
+ % (int(length), MAX_ENTRY_LENGTH)
285
+ )
240
286
  for addr in addresses:
241
287
  self.add_entry(
242
288
  CheatEntry(
@@ -356,8 +402,9 @@ class CheatTable(QWidget):
356
402
  # next poll tick reads the value back and corrects the cell if the
357
403
  # write didn't take, and an outright failure returns via
358
404
  # write_failed → _on_write_failed.
359
- # Nothing to route it to once shutdown() handed the worker over.
360
- if self._poller_stopped:
405
+ # Nothing to route it to once shutdown() handed the worker over,
406
+ # and nothing to write to while the target is known to be gone.
407
+ if self._poller_stopped or self._paused:
361
408
  return
362
409
  self._poller.request_write(
363
410
  entry.address, entry.spec.pytype, entry.length, value
@@ -469,13 +516,33 @@ class CheatTable(QWidget):
469
516
  def _on_write_failed(self, failure) -> None:
470
517
  """Surface a manual value write that failed on the worker thread.
471
518
 
472
- The write is now async (queued onto the poll worker), so its failure
473
- arrives here via the ``write_failed`` signal rather than inline. The
474
- optimistic cell update made when the edit was queued is corrected by the
475
- next poll tick, which reads back the unchanged value.
519
+ Writes are async (queued onto the poll worker), so a failure arrives
520
+ here via the ``write_failed`` signal rather than inline. The optimistic
521
+ cell update made when the edit was queued is corrected by the next poll
522
+ tick, which reads back the unchanged value.
523
+
524
+ Failures are collected and shown by ``_flush_write_failures`` on the
525
+ next event-loop pass: the worker drains a bulk edit's queue in one
526
+ tick, so its failures land here back to back and read as one report.
476
527
  """
477
- _address, _pytype, _length, message = failure
478
- QMessageBox.critical(self, "Write Failed", message)
528
+ address, _pytype, _length, message = failure
529
+ if not self._pending_write_failures:
530
+ QTimer.singleShot(0, self._flush_write_failures)
531
+ self._pending_write_failures.append((address, message))
532
+
533
+ def _flush_write_failures(self) -> None:
534
+ failures = self._pending_write_failures
535
+ self._pending_write_failures = []
536
+ if not failures:
537
+ return
538
+ if len(failures) == 1:
539
+ QMessageBox.critical(self, "Write Failed", failures[0][1])
540
+ return
541
+ preview = "\n".join(f"0x{addr:X}: {msg}" for addr, msg in failures[:10])
542
+ extra = "" if len(failures) <= 10 else f"\n…and {len(failures) - 10} more."
543
+ QMessageBox.critical(
544
+ self, "Write Failed", f"{len(failures)} write(s) failed:\n\n{preview}{extra}"
545
+ )
479
546
 
480
547
  def _editing_row(self) -> int:
481
548
  """Return the row currently being edited, or -1 if none."""
@@ -603,15 +670,20 @@ class CheatTable(QWidget):
603
670
  if spec.accepts_length_override:
604
671
  entry.length = effective_length
605
672
 
606
- try:
607
- self._process.write_process_memory(
608
- entry.address, spec.pytype, entry.length, value
609
- )
610
- except Exception as exc: # noqa: BLE001
673
+ # Same path as an inline edit: the syscall runs on the poll
674
+ # worker, never on the UI thread (a slow target or a page
675
+ # fault would freeze the interface for every row). A write
676
+ # that fails comes back via write_failed → one coalesced
677
+ # dialog; the optimistic update below is corrected by the
678
+ # next tick's read-back.
679
+ if self._poller_stopped or self._paused:
611
680
  failures.append(
612
- (entry.address, f"{type(exc).__name__}: {exc}")
681
+ (entry.address, "the target process is no longer available")
613
682
  )
614
683
  continue
684
+ self._poller.request_write(
685
+ entry.address, spec.pytype, entry.length, value
686
+ )
615
687
 
616
688
  entry.last_value = value
617
689
  if entry.frozen:
@@ -873,7 +945,7 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]:
873
945
  "Buffer length (bytes):",
874
946
  value=spec.length or 4,
875
947
  minValue=1,
876
- maxValue=1024,
948
+ maxValue=MAX_ENTRY_LENGTH,
877
949
  )
878
950
  if not ok:
879
951
  return None
@@ -31,7 +31,7 @@ from PySide6.QtWidgets import (
31
31
  QVBoxLayout,
32
32
  )
33
33
 
34
- from ._widgets import TearsDownOnClose
34
+ from ._widgets import MONOSPACE_FAMILY, TearsDownOnClose
35
35
 
36
36
 
37
37
  _LOGGER_NAME = "PyMemoryEditor"
@@ -146,7 +146,7 @@ class LogConsoleDialog(TearsDownOnClose, QDialog):
146
146
 
147
147
  self._console = QPlainTextEdit()
148
148
  self._console.setReadOnly(True)
149
- self._console.setFont(QFont("Menlo, Consolas, Courier New", 10))
149
+ self._console.setFont(QFont(MONOSPACE_FAMILY, 10))
150
150
  self._console.setLineWrapMode(QPlainTextEdit.NoWrap)
151
151
  # Cap the buffer so a long-running auto-scan doesn't grow the dialog
152
152
  # memory unboundedly. ~5000 lines is plenty for live debugging.