fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(app): stop the process-list refresh from stomping on scroll position and typed input - #84

Merged
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position
Aug 19, 2026
Merged

fix(app): stop the process-list refresh from stomping on scroll position and typed input#84
JeanExtreme002 merged 7 commits into
mainfrom
fix/process-list-scroll-position

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes#75.

The picker's 3 s auto-refresh — and, it turned out, its Filter box and its own click handling — could take the user's scroll position, their typed input, and even their choice of process. All of it lives in one small cluster of handlers in open_process_dialog.py.

1. The scroll position (the issue)

Each tick restored the previously selected row with QTableView.selectRow. That moves the current index, and QAbstractItemView::currentChanged scrolls the view to the current index — so once the user had clicked any row, the list jumped back to that row on every tick and scrolling through a long process list was impossible.

Measured offscreen with 300 rows: click row 2, scroll to offset 150, one refresh tick → offset 2.

The fix takes the scroll offset before the model is rebuilt and puts it back after the selection is restored. Rebuilding the model is not the problem on its own — Qt defers the scrollbar range update, so the offset survives setRowCount(0) plus 300 appendRows (150 before, 150 after, with no selection). That is why the restore has to sit after the selectRow call rather than merely around the rebuild.

Two alternatives were rejected:

  • Restoring the selection with selectionModel().select() instead, so the current index is never moved. It does preserve the scroll position, but it leaves the current index invalid after every tick, which costs keyboard navigation.
  • Anchoring on the top visible row's PID and using scrollTo(..., PositionAtTop). More exact — restoring a raw ScrollPerItem value is a row index, so the view drifts by one row when a process sorting above the viewport starts or exits — but the picker now matches the save/restore pattern already used in threads_dialog.py, modules_dialog.py and memory_map_dialog.py. Worth revisiting for all four at once if the drift ever matters.

The auto-refresh itself is kept: the issue also suggests dropping it in favour of the existing Refresh button, but with the viewport preserved it no longer interrupts anyone.

2. The typed process name

Restoring the selection emits selectionChanged, and _on_selection_changed mirrored the selected PID into the "Process:" field unconditionally. Click a row, type a process name, and 3 s later the field held the old PID again — so pressing Enter opened the process that was clicked instead of the one that was typed. Against a live process list the typed text survived 1.7 s.

This one needed the click first: typing without ever selecting a row was always safe, and the Filter box at the top was never affected, which is why it went unnoticed.

The echo is right for a click and wrong for a refresh, so the restore now runs inside _programmatic_selection(), and _on_selection_changed ignores selection changes made under it. Blocking the selection model's signals instead would also have suppressed the view's own repaint of the row it just selected.

3. Clicking a row that is already highlighted

_try_open only ever reads the entry, and clicking a row that is already selected emits no selectionChanged in single-selection mode — so nothing re-synced the entry. Fixing (2) is what made this reachable: before, the next tick overwrote the entry and papered over it.

Reproduced with real mouse events: click a row (entry becomes 1296), type a name, let a tick restore the selection, then click that same highlighted row — the entry still says notepad.exe while the row for 1296 is selected, and Open Process opens the name. With an emptied entry, the same click leaves Open warning "Type a PID or process name first" while a row sits highlighted.

clicked now aims the entry at the row it carries, and _on_row_activated (double-click) reuses that step before opening. A real double-click emits clicked then doubleClicked — verified by delivering the four mouse events — so the entry is aimed on the first release and _try_open still runs exactly once.

4. The Filter box retargeting the selection

Hiding the selected row does not clear the selection: Qt remaps it onto whatever row took that index. Measured — pick pid 1179, type a name, filter to proc12, and the entry reads 1129, a process the user never chose; the next tick then cements it. The remap is now refused (the selection is dropped when the filter hides it, kept when it doesn't) and neither the remap nor that cleanup reaches the entry.

Tests

New tests/app/test_open_process_dialog.py, following the conventions in test_auto_refresh_dialog.py (module-scoped qapp, offscreen platform, the enumeration worker stubbed out so the live process table can't land mid-test). Ten tests covering: the #75 repro (asserting the restored selection stayed outside the viewport, not just the scrollbar number), the selection surviving a tick, the typed name surviving a tick, a click and an arrow-key pick both still filling the entry, a click on the already-highlighted row re-aiming it without opening anything, the double-click opening the row it carries, the filter dropping a hidden pick instead of retargeting it, the filter keeping a visible one, and a tick under an active filter finding the picked PID among the filtered rows.

Each fix was mutation-checked: dropping the scroll restore, clearing the selection on any filter keystroke, swallowing every echo, un-wiring clicked, wiring clicked to the open path, and giving doubleClicked its old index-discarding lambda back each fail the tests that cover them.

tests/app is 113 passed; mypy and flake8 are clean. The 15 failures in the full suite are pre-existing macOS permission failures, unrelated (same 15 on a clean tree).

The process picker re-enumerates every 3 s, and each tick restored the
previously selected row with `QTableView.selectRow`. That moves the
current index, and `QAbstractItemView::currentChanged` scrolls the view
to it - so once any row had been clicked, the list jumped back to that
row on every tick and scrolling through a long process list was
impossible.
The scroll offset is now taken before the model is rebuilt and put back
after the selection is restored. Rebuilding the model is not the problem
on its own: Qt defers the scrollbar range update, so the offset survives
it - measured at 150 before and after a 300-row rebuild with no
selection, which is also why the fix has to sit after the `selectRow`
call rather than around the rebuild.
The regression test asserts the property the issue is about, not just the
scrollbar number: after a refresh tick the restored selection must still
be outside the viewport.
Closes#75
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 19, 2026
Same tick, second casualty. Restoring the selection emits
`selectionChanged`, and `_on_selection_changed` mirrors the selected PID
into the "Process:" field unconditionally. Click a row, type a process
name, and 3 s later the field holds the old PID again - so pressing Enter
opens the process that was clicked rather than the one that was typed.
Measured against a live process list: the typed text survived 1.7 s.
The echo is right for a click and wrong for a refresh, so the restore now
goes through `_restore_selection`, which flags the selection change as
programmatic; `_on_selection_changed` ignores those. Blocking the
selection model's signals instead would also have suppressed the view's
own repaint of the row it just selected.
The clearing half of the rebuild never had the bug: it leaves nothing
selected, and the handler already declines to write an empty selection
into the field.
@JeanExtreme002JeanExtreme002 changed the title fix(app): keep the process list scroll position across refreshesfix(app): stop the process-list refresh from stomping on scroll position and typed inputAug 19, 2026
Two ways the picker still pointed somewhere the user hadn't chosen, both
found reviewing the previous commit.
Double-clicking the already-selected row opened whatever the entry held.
Clicking a row that is already selected emits no `selectionChanged` in
single-selection mode, so nothing re-synced the entry, and `_try_open`
only ever reads the entry. Before the previous commit the 3 s tick
bounded that divergence by overwriting the entry; suppressing the echo
made it permanent. `doubleClicked` already carries the index, so the
click is now authoritative: it fills the entry with that row's PID and
opens it.
Typing in the Filter box retargeted the selection silently. Hiding the
selected row doesn't clear the selection - Qt remaps it onto whatever row
took that index - and the remap echoed a different process's PID into the
entry. Measured: pick pid 1179, type a name, filter to "proc12", and the
entry read 1129, a process never chosen. The remap is now refused (the
selection is dropped when the filter hides it) and neither it nor the
cleanup reaches the entry.
The refresh-tick guard grew into `_programmatic_selection`, since the
filter path needs the same suppression and the reason is identical: the
entry mirrors a *pick*, and neither a tick nor a keystroke is one.
Review pass over the three commits before this one. No behaviour changes -
the fixes hold under probing (an active filter, a user-chosen sort order,
offsets 0/1/mid/clamped, an exception thrown inside the guard, arrow-key
picks, a double-click on a row other than the selected one), and every
fix is caught by mutating it: dropping the scroll restore, clearing the
selection on any filter keystroke, swallowing every echo, or giving
`doubleClicked` its old index-discarding lambda back each fail the tests
that cover them.
Two gaps in the cover, both interactions rather than single behaviours:
* Arrowing onto a row is a pick too, and the echo guard has to let it
through. Nothing tested the keyboard path, so a guard that swallowed
every selection change would have passed.
* A tick under an active filter has to find the picked PID among the
*filtered* rows. The two fixes compose there, and over-clearing on the
filter side would have gone unnoticed.
Also: annotate `_programmatic_selection`, the only method in the class
without a return type, and move the test's `Qt` import into the test that
uses it - every other PySide6 import under tests/app is function-local,
and this was the one module-level exception. The double-click test uses
`monkeypatch.setattr` rather than assigning over the bound method.
The comments that came in with the three fixes restated the code or told
the story of how each bug was found - the commit messages already carry
that. Kept the notes a future edit would need: why the scroll restore has
to sit after `selectRow`, that Qt remaps a selection whose row the filter
hid, and that re-clicking a selected row emits no `selectionChanged`.
Same pass over the tests: the module docstring states the contract instead
of narrating three bugs, the per-test docstrings are one line each, and the
inline notes that survive are the ones tying a magic value to `_rows`.
`test_clicking_a_row_still_fills_the_entry` opened with "The guard above
must only cover the refresh" - the explanation it pointed at was in the
previous test's docstring, which the trim cut to one line, so the
reference dangled. The double-click test's docstring lost its verb in the
same pass and no longer parsed.
The other two now say what they check instead of naming "the guard" and
"the two fixes", neither of which a reader can resolve from this file.
Review follow-up. The previous commit fixed the double-click path and left
the single click behind: clicking the row that is already highlighted emits
no `selectionChanged`, so nothing re-synced the "Process:" field. Reproduced
with real mouse events - click a row (entry becomes 1296), type a name, let
a tick restore the selection, then click that same highlighted row: the
entry still says `notepad.exe` while the row for 1296 is selected, and Open
Process opens the name. With an emptied entry the same click leaves Open
warning "Type a PID or process name first" while a row sits highlighted.
`clicked` now aims the entry at the row it carries, which is the step the
double-click already needed, so `_on_row_activated` reuses it. A real
double-click emits `clicked` then `doubleClicked` (verified by delivering
the four mouse events), so the entry is aimed on the first release and
`_try_open` still runs exactly once.
That also settles the other half of the review: a failed open no longer
"eats" typed text, because the click that preceded it already replaced the
text - aiming the picker is what a click means, not a side effect of the
open attempt.
@JeanExtreme002
JeanExtreme002 merged commit 29d2f69 into mainAug 19, 2026
14 checks passed
@github-actions
github-actionsBot deleted the fix/process-list-scroll-position branch August 19, 2026 04:06
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process list automatic refresh does not retain scroll position

1 participant

@JeanExtreme002