From eea10d6230b1d6c460767b701fcd58d2f965c7b4 Mon Sep 17 00:00:00 2001 From: blasty Date: Sun, 26 Jul 2026 10:03:05 +0200 Subject: app: one way to preserve view state across a model rebuild (ViewAnchor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in two days had the same shape — an edit rebuilds the model and whatever ran last wins. The status message got clobbered by the reload's own status write; the scroll position got recomputed from a row index that no longer meant the same thing. Both were patched by hand. A third was coming. ViewAnchor makes it one thing: where you are looking, in ADDRESSES, plus the message the rebuild must not eat. _anchor() captures it on the UI thread before the edit; _anchor_rows() resolves it against the rebuilt model; _edit_done() settles the aftermath. The edit paths (_do_edit_item, _do_make_data) now hand one object through instead of threading positions and messages separately. Addresses, not indices, because an edit can change how many rows an item takes: four undefined byte rows collapse into one instruction row, undefining does the reverse. An index means a different place afterwards. _reload_active_code deliberately does NOT use it. Renames and comments don't change row structure, and the model that path rebuilds is constructed empty — index_of_ea returns -1 until pages load, so an anchor would resolve to nothing while costing an extra model build on the UI thread. Wrote that out and reverted it rather than leave an abstraction applied where it does nothing. Also fixed in passing: _do_make_data had the same latent bug (no scroll preservation at all) and now goes through the same path. The bigger find is in TODO. The scenario suite mutates targets/echo.i64 and SAVES it, so a scenario that undefines an instruction breaks later runs permanently — decomp_follow_self had been failing on a polluted database, not on any code change. It also made an edit-position check look flaky one run in three, which I nearly wrote up as a race. Coverage for this lives in test_blob_ui.py instead, which builds a throwaway binary and can mutate freely. tests: +1 blob UI (commenting leaves the view where it was), alongside the carve checks. 26/0 blob, 202/0 scenarios (on a fresh .i64), 30/0 project UI. --- tests/test_blob_ui.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py index ca4dbb3..f930401 100644 --- a/tests/test_blob_ui.py +++ b/tests/test_blob_ui.py @@ -14,7 +14,7 @@ import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from textual.widgets import Static # noqa: E402 +from textual.widgets import Input, Static # noqa: E402 from idatui.app import ConfirmScreen, IdaTui, ListingView # noqa: E402 @@ -163,6 +163,33 @@ async def run() -> int: m.get(m.index_of_ea(target - 1)).size == 1 and m.get(m.index_of_ea(target - 1)).ea == target - 1) + # -- an edit must not move the view -------------------------- # + # Every mutation rebuilds the model, and row indices don't survive + # that: carving collapses four byte rows into one instruction row. + # All the edit paths go through ViewAnchor, which remembers + # ADDRESSES. This runs in its own app, so unlike the shared scenario + # suite it can mutate the database freely. + lst.cursor = m.index_of_ea(0x4200) + lst._scroll_cursor_into_view() + await pilot.pause(0.4) + ctop = lst.model.get(round(lst.scroll_offset.y)).ea + ccur = lst._cursor_ea() + old = lst.model + await pilot.press("semicolon") + await wait(lambda: app.query_one("#comment", Input).display, pilot, 20) + for ch in "note": + await pilot.press(ch) + await pilot.press("enter") + await wait(lambda: lst.model is not old and lst.model is not None, + pilot, 30) + await pilot.pause(0.4) + check("commenting leaves the view where it was", + lst.model.get(round(lst.scroll_offset.y)).ea == ctop + and lst._cursor_ea() == ccur, + f"top {ctop:#x} -> " + f"{lst.model.get(round(lst.scroll_offset.y)).ea:#x}, " + f"cursor {ccur:#x} -> {lst._cursor_ea():#x}") + # -- carving must not move the view -------------------------- # # Defining code collapses rows (four byte rows become one # instruction), so anything that remembers a row INDEX puts you -- cgit v1.3.1-sl0p