From 26869018f61878446cd9fb9422be07493f36fca9 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 7 Aug 2026 02:41:31 +0200 Subject: Search the whole segment as ONE joined string. Every line is concatenated once (with a start-offset table) so finding a term is a C-level str.find walk instead of a python loop that rebuilds and case-folds 224k lines per keystroke. Falls back to the per-line loop if case-folding changes the string's length. Result: {"status":"keep","total_ms":18618.9,"lg_boot_ms":689.6,"lg_decomp_ms":2560,"lg_graph_ms":1035.1,"lg_hex_ms":684.2,"lg_index_ms":100.4,"lg_listing_cold_ms":551.1,"lg_listing_warm_ms":397.8,"lg_nav_ms":6687.8,"lg_palette_ms":4.7,"lg_render_ms":211.9,"lg_search_ms":1858.2,"pure_graph_ms":238.5,"sm_boot_ms":431.4,"sm_decomp_ms":671.2,"sm_graph_ms":728.1,"sm_hex_ms":552,"sm_index_ms":0,"sm_listing_cold_ms":257.4,"sm_listing_warm_ms":281,"sm_nav_ms":365.9,"sm_palette_ms":0.3,"sm_render_ms":242.8,"sm_search_ms":69.6,"fails":0} --- idatui/app.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 26 deletions(-) (limited to 'idatui') diff --git a/idatui/app.py b/idatui/app.py index 1cdb8a8..abdaa1e 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -644,11 +644,58 @@ class SearchMixin: #: those changing under us discards it. _matched_key: tuple | None = None + #: The searchable body as ONE string (see :meth:`_search_haystack`), its + #: case-folded twin, the character offset each line starts at, and the key + #: they were built for. Dropped when the search ends. + _hay_key: tuple | None = None + _hay: tuple | None = None + def _search_source_id(self) -> int: """Identity of whatever supplies the line text. Changes when the view is pointed at a different model/body, which invalidates a narrowing.""" return id(getattr(self, "model", None) or getattr(self, "_texts", None)) + def _reset_search_cache(self) -> None: + """Forget both the narrowing key and the joined body. Called from every + place that resets ``_matches``/``_ranges``: a stale prefix would make the + next search narrow from an empty list, and a stale body would search + text the view no longer shows.""" + self._matched_key = None + self._hay_key = None + self._hay = None + + def _search_haystack(self, count: int, src: int): + """``(starts, blob, blob_folded)`` for the whole body, or None. + + Every line joined with newlines, so finding a term is one C-level + ``str.find`` walk over the segment instead of a python loop that rebuilds + and case-folds 200 000 lines per keystroke. ``starts[i]`` is where line + ``i`` begins; a term can never straddle a line because it cannot contain + a newline. + + Returns None (and the caller falls back to the per-line loop) if folding + changes the length — a couple of unicode codepoints grow when lowered, + and then every offset after them would be wrong. + """ + key = (count, src) + if self._hay_key == key: + return self._hay + text_of = self._search_line_text + starts: list[int] = [] + parts: list[str] = [] + pos = 0 + for i in range(count): + s = text_of(i) or "" + starts.append(pos) + parts.append(s) + pos += len(s) + 1 + blob = "\n".join(parts) + folded = blob.lower() + hay = None if len(folded) != len(blob) else (starts, blob, folded) + self._hay_key = key + self._hay = hay + return hay + # --- hooks a subclass implements --- def _search_line_count(self) -> int: raise NotImplementedError @@ -680,7 +727,7 @@ class SearchMixin: self._term = "" self._matches = [] self._ranges = {} - self._matched_key = None + self._reset_search_cache() self.cursor = getattr(self, "_search_origin", self.cursor) self.cursor_x = getattr(self, "_search_origin_x", self.cursor_x) self.refresh() @@ -719,7 +766,7 @@ class SearchMixin: self._term = "" self._matches = [] self._ranges = {} - self._matched_key = None + self._reset_search_cache() self.cursor = getattr(self, "_search_origin", self.cursor) self.cursor_x = getattr(self, "_search_origin_x", self.cursor_x) self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False) @@ -751,29 +798,57 @@ class SearchMixin: # The row count is part of the key because the listing streams in behind # the search: rows that arrived after the last pass have never been # looked at, and narrowing would silently never find them. - rows: object = range(count) - prev = self._matched_key - if (prev is not None and prev[2] == count and prev[3] == src - and prev[1] == ci and term.startswith(prev[0]) and prev[0]): - rows = self._matches + n = len(term) matches: list[int] = [] ranges: dict[int, list[tuple[int, int]]] = {} - text_of = self._search_line_text - n = len(term) - for i in rows: - s = text_of(i) - if not s: - continue - hay = s.lower() if ci else s - j = hay.find(needle) - if j < 0: - continue - rs = [] + hay = self._search_haystack(count, src) + if hay is not None: + starts, blob, folded = hay + body = folded if ci else blob + nlines = len(starts) + line = 0 + j = body.find(needle) while j >= 0: - rs.append((j, j + n)) - j = hay.find(needle, j + n) - matches.append(i) - ranges[i] = rs + # find() walks forward, so the line only ever advances. + while line + 1 < nlines and starts[line + 1] <= j: + line += 1 + off = j - starts[line] + rs = ranges.get(line) + if rs is None: + ranges[line] = [(off, off + n)] + matches.append(line) + else: + rs.append((off, off + n)) + j = body.find(needle, j + n) + else: + # Typing forward can only ever REMOVE lines: a line holding "mov" + # holds "mo". So when the term just grew (and nothing else moved -- + # same case-folding, same body, same number of rows) rescan only the + # previous hits. + # + # The row count is part of the key because the listing streams in + # behind the search: rows that arrived after the last pass have never + # been looked at, and narrowing would silently never find them. + rows: object = range(count) + prev = self._matched_key + if (prev is not None and prev[2] == count and prev[3] == src + and prev[1] == ci and term.startswith(prev[0]) and prev[0]): + rows = self._matches + text_of = self._search_line_text + for i in rows: + s = text_of(i) + if not s: + continue + h = s.lower() if ci else s + j = h.find(needle) + if j < 0: + continue + rs = [] + while j >= 0: + rs.append((j, j + n)) + j = h.find(needle, j + n) + matches.append(i) + ranges[i] = rs self._matches = matches self._ranges = ranges self._matched_key = (term, ci, count, src) @@ -810,7 +885,7 @@ class SearchMixin: self._term = "" self._matches = [] self._ranges = {} - self._matched_key = None + self._reset_search_cache() self.refresh() def _match_style(self, idx: int) -> Style: @@ -997,7 +1072,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._pending_op = None self._matches = [] self._ranges = {} - self._matched_key = None + self._reset_search_cache() self._prime() @work(thread=True, exclusive=True, group="listing-prime") @@ -1072,7 +1147,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._op_mode = (self._op_mode + 1) % 3 self._update_op_w() self._ranges = {} # column layout changed -> stale match offsets - self._matched_key = None # ...and which rows match at all + self._reset_search_cache() # ...and which rows match at all self._clamp_x() self.refresh() self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)", @@ -1508,7 +1583,7 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self.cursor_x = cursor_x self._matches = [] self._ranges = {} - self._matched_key = None + self._reset_search_cache() # Gutter wide enough for the largest line number + a trailing space. self._gutter = (len(str(total)) + 1) if total else 0 maxw = max((s.cell_length for s in self._strips), default=0) -- cgit v1.3.1-sl0p