fastpygrid 0.1.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastgrid/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """fastgrid — a fast grid split into a GUI-free core and thin toolkit hosts.
2
+
3
+ core/ model + geometry + selection + paint() + the Direct2D engine (gpu.py)
4
+ render/ gpu_tk.py (tkinter host) · gpu_qt.py (PySide6 host)
5
+
6
+ Both hosts drive the SAME Direct2D engine, so behaviour and looks match.
7
+
8
+ from fastgrid.render.gpu_tk import make_sheet # tkinter
9
+ win = make_sheet(headers, rows, frozen_columns=2); win.mainloop()
10
+ """
11
+ from .core import GridModel, selection, theme
12
+
13
+ __all__ = ["GridModel", "selection", "theme"]
@@ -0,0 +1,8 @@
1
+ """GUI-free core: model + geometry + selection + display-list paint.
2
+ Import nothing from any toolkit here."""
3
+ from . import selection, theme
4
+ from .model import GridModel
5
+ from .geometry import Geometry
6
+ from .paint import paint, DisplayList
7
+
8
+ __all__ = ["GridModel", "Geometry", "paint", "DisplayList", "selection", "theme"]
Binary file
Binary file
@@ -0,0 +1,359 @@
1
+ """CoreModel -- GridModel backed by the C++ arena engine (gridcore.dll).
2
+
3
+ Cell text lives in C++; the bulk text paths (copy/delete/paste/find) and undo
4
+ run there in one ctypes call each, avoiding the millions of Python str objects
5
+ that are the pure-Python floor. Everything else (view/filter/sort orchestration,
6
+ styles, choices, readonly, geometry) stays in Python: those touch one column or
7
+ the visible viewport, where per-cell access is already cheap.
8
+
9
+ `self._rows` is a thin shim delegating to the engine, so every inherited
10
+ GridModel method keeps working unchanged; only the handful of bulk hot methods
11
+ are overridden to call C++ directly.
12
+
13
+ Scope: data-row mutations. Header rows are read-only through the fast path
14
+ (headers stay Python, as in GridModel); editing a header cell is not routed
15
+ through this backend. Falls back to GridModel if the DLL is unavailable.
16
+ """
17
+ import ctypes
18
+ import os
19
+ import struct
20
+
21
+ from .model import GridModel, PAD_ROWS, _clean, _grow, _norm_ranges
22
+
23
+ _DLL = os.path.join(os.path.dirname(__file__), "_gridstore", "gridcore.dll")
24
+
25
+
26
+ def _load():
27
+ if not os.path.exists(_DLL):
28
+ return None
29
+ try:
30
+ lib = ctypes.CDLL(_DLL)
31
+ except OSError:
32
+ return None
33
+ P, I, C = ctypes.c_void_p, ctypes.c_int, ctypes.c_char_p
34
+ IP = ctypes.POINTER(I)
35
+ sig = {
36
+ "gc_new": ([I, I], P), "gc_free": ([P], None),
37
+ "gc_ndata": ([P], I), "gc_grow_cols": ([P, I], None),
38
+ "gc_load_packed": ([P, C, I], None),
39
+ "gc_cell": ([P, I, I], C), "gc_set_raw": ([P, I, I, C], I),
40
+ "gc_set_view": ([P, IP, I], None),
41
+ "gc_copy": ([P, I, I, I, I, IP], P),
42
+ "gc_delete": ([P, IP, I, IP, I, IP, I, IP], I),
43
+ "gc_paste": ([P, I, I, C, I, IP, I, IP, I, I, I, IP], I),
44
+ "gc_set_cell": ([P, I, I, C, IP, I, IP, I], I),
45
+ "gc_undo": ([P, IP], I), "gc_redo": ([P, IP], I),
46
+ "gc_find": ([P, C, I, I, IP, I, I, IP, IP], P),
47
+ }
48
+ for name, (args, res) in sig.items():
49
+ fn = getattr(lib, name)
50
+ fn.argtypes, fn.restype = args, res
51
+ return lib
52
+
53
+
54
+ _LIB = _load()
55
+
56
+
57
+ def _iarr(seq):
58
+ """A ctypes int array (or None) from a Python iterable."""
59
+ seq = list(seq)
60
+ if not seq:
61
+ return None, 0
62
+ return (ctypes.c_int * len(seq))(*seq), len(seq)
63
+
64
+
65
+ def _pack(rows):
66
+ """Length-prefixed (u32 len + utf-8 bytes) row-major buffer for gc_load_packed."""
67
+ parts = []
68
+ for r in rows:
69
+ for c in r:
70
+ b = c.encode("utf-8")
71
+ parts.append(struct.pack("<I", len(b)))
72
+ parts.append(b)
73
+ return b"".join(parts)
74
+
75
+
76
+ def make_model(headers, rows, editable=True, on_edit=None):
77
+ """CoreModel (C++ backend) when gridcore.dll is available, else the pure
78
+ Python GridModel. Drop-in: identical public API."""
79
+ cls = CoreModel if _LIB else GridModel
80
+ return cls(headers, rows, editable=editable, on_edit=on_edit)
81
+
82
+
83
+ class _CoreRow:
84
+ __slots__ = ("_c", "_r")
85
+
86
+ def __init__(self, core, r):
87
+ self._c, self._r = core, r
88
+
89
+ def __getitem__(self, col):
90
+ return _LIB.gc_cell(self._c, self._r, col).decode("utf-8")
91
+
92
+
93
+ class _CoreRows:
94
+ """Read shim making `self._rows[r][c]` delegate to the C++ engine, so inherited
95
+ GridModel code (sort/filter/distinct/cell/style) works unchanged. Reads only:
96
+ every inherited mutation path (set_cell/paste/delete/undo/grow) is overridden
97
+ to call the engine directly."""
98
+ __slots__ = ("_c",)
99
+
100
+ def __init__(self, core):
101
+ self._c = core
102
+
103
+ def __len__(self):
104
+ return _LIB.gc_ndata(self._c)
105
+
106
+ def __getitem__(self, r):
107
+ return _CoreRow(self._c, r)
108
+
109
+ def __iter__(self):
110
+ for r in range(len(self)):
111
+ yield _CoreRow(self._c, r)
112
+
113
+
114
+ class CoreModel(GridModel):
115
+ # ---- storage: build the engine instead of a Python matrix ----
116
+ def set_data(self, headers, rows):
117
+ if headers and isinstance(headers[0], (list, tuple)):
118
+ hdr = [[str(h) for h in hrow] for hrow in headers]
119
+ else:
120
+ hdr = [[str(h) for h in headers] or ["A"]]
121
+ w = max(len(hrow) for hrow in hdr)
122
+ self._headers = [hrow + [""] * (w - len(hrow)) for hrow in hdr]
123
+ self._hdr = len(self._headers)
124
+ self._w = w
125
+
126
+ data = [([str(c) for c in r][:w] + [""] * (w - len(r))) for r in rows]
127
+ if getattr(self, "_core", None):
128
+ _LIB.gc_free(self._core)
129
+ self._core = _LIB.gc_new(w, 0) # hdr=0: engine holds DATA rows only
130
+ if data:
131
+ _LIB.gc_load_packed(self._core, _pack(data), len(data))
132
+ self._rows = _CoreRows(self._core)
133
+ self._init_view_state() # _undo holds tagged ops; cell diffs live in C++
134
+
135
+ def __del__(self):
136
+ c = getattr(self, "_core", None)
137
+ if c and _LIB:
138
+ _LIB.gc_free(c)
139
+
140
+ # ---- view: keep GridModel's Python rebuild, then push the mapping to C++ ----
141
+ def _rebuild(self):
142
+ super()._rebuild()
143
+ if self._is_plain():
144
+ _LIB.gc_set_view(self._core, None, -1)
145
+ else:
146
+ arr, n = _iarr(self._view)
147
+ _LIB.gc_set_view(self._core, arr, n)
148
+
149
+ def _ro_arrays(self):
150
+ rc, n_rc = _iarr(sorted(self._readonly))
151
+ rr, n_rr = _iarr(sorted(k for k in self._readonly_rows if k >= 0)) # data rows only
152
+ return rc, n_rc, rr, n_rr
153
+
154
+ def _touch(self, cols):
155
+ for c in cols:
156
+ self._distinct.pop(c, None)
157
+
158
+ def _push_snap(self, rng):
159
+ # Cell diff lives in the C++ engine; the Python entry carries the view state
160
+ # active at this edit + the touched rect (to reselect on undo), tagged to
161
+ # interleave with 'view' ops.
162
+ self._undo.append(("edit", self._filt_snapshot(), rng))
163
+ del self._undo[:-200]
164
+ self._redo.clear()
165
+
166
+ # ---- bulk hot paths -> C++ ----
167
+ def selection_text(self, ranges):
168
+ rs = _norm_ranges(ranges)
169
+ if not rs:
170
+ return ""
171
+ r1 = max(0, min(r[0] for r in rs)); c1 = max(0, min(r[1] for r in rs))
172
+ r2 = max(r[2] for r in rs); c2 = min(self._w - 1, max(r[3] for r in rs))
173
+ H = self._hdr
174
+ lines = []
175
+ for gr in range(r1, min(H, r2 + 1)): # header rows (Python)
176
+ lines.append("\t".join(_clean(self.cell(gr, c)) for c in range(c1, c2 + 1)))
177
+ d_lo = max(r1, H) # data rows (C++)
178
+ if d_lo <= r2:
179
+ n = ctypes.c_int()
180
+ p = _LIB.gc_copy(self._core, d_lo - H, c1, r2 - H, c2, ctypes.byref(n))
181
+ lines.append(ctypes.string_at(p, n.value).decode("utf-8"))
182
+ return "\n".join(lines)
183
+
184
+ def delete_selection(self, ranges):
185
+ if not self.editable:
186
+ return False
187
+ H = self._hdr
188
+ rects, box = [], None
189
+ for r1, c1, r2, c2 in _norm_ranges(ranges):
190
+ r1 = max(r1, H) # data rows only
191
+ if r1 <= r2:
192
+ rects.extend((r1 - H, c1, r2 - H, c2))
193
+ box = _grow(_grow(box, r1, c1), r2, c2) # grid rect to reselect on undo
194
+ if not rects:
195
+ return False
196
+ ra = (ctypes.c_int * len(rects))(*rects)
197
+ rc, n_rc, rr, n_rr = self._ro_arrays()
198
+ tgt = (ctypes.c_int * 2)()
199
+ changed = _LIB.gc_delete(self._core, ra, len(rects) // 4, rc, n_rc, rr, n_rr, tgt)
200
+ if changed:
201
+ cols = {c for i in range(0, len(rects), 4) for c in range(max(0, rects[i + 1]),
202
+ min(self._w, rects[i + 3] + 1))}
203
+ self._touch(cols)
204
+ self._push_snap(box)
205
+ self._find_cache = None
206
+ self._used = None
207
+ if not self._is_plain():
208
+ self._rebuild()
209
+ if self.on_edit:
210
+ self.on_edit()
211
+ self.changed()
212
+ return bool(changed)
213
+
214
+ def paste_text(self, text, ranges, active):
215
+ if not self.editable or not text:
216
+ return None
217
+ rs = _norm_ranges(ranges)
218
+ if rs:
219
+ start_gr = min(r[0] for r in rs); start_col = min(r[1] for r in rs)
220
+ sel_r2 = max(r[2] for r in rs); sel_c2 = max(r[3] for r in rs)
221
+ else:
222
+ start_gr, start_col = active
223
+ sel_r2, sel_c2 = active
224
+ H = self._hdr
225
+ d_start = max(0, start_gr - H) # header-row paste not routed
226
+ rc, n_rc, rr, n_rr = self._ro_arrays()
227
+ pre = _LIB.gc_ndata(self._core)
228
+ # C++ parses the raw clipboard, fills a 1x1 over a multi-cell selection,
229
+ # and returns the block dims -- no Python split/scan.
230
+ payload = text.encode("utf-8")
231
+ dims = (ctypes.c_int * 2)()
232
+ changed = _LIB.gc_paste(self._core, d_start, start_col, payload, len(payload),
233
+ rc, n_rc, rr, n_rr, sel_r2 - start_gr + 1,
234
+ sel_c2 - start_col + 1, dims)
235
+ nblock, maxw = dims[0], dims[1]
236
+ if nblock == 0: # clipboard was all-blank
237
+ return None
238
+ if changed:
239
+ if _LIB.gc_ndata(self._core) > pre: # materialised -> all columns gained a blank
240
+ self._distinct.clear()
241
+ else:
242
+ self._touch(range(start_col, start_col + maxw))
243
+ self._push_snap((start_gr, start_col,
244
+ start_gr + nblock - 1, start_col + maxw - 1))
245
+ self._find_cache = None
246
+ self._used = None
247
+ if not self._is_plain():
248
+ self._rebuild()
249
+ if self.on_edit:
250
+ self.on_edit()
251
+ self.changed()
252
+ end_gr = start_gr + nblock - 1
253
+ end_col = start_col + maxw - 1
254
+ return (start_gr, start_col, min(end_gr, self.nrows() - 1), min(end_col, self._w - 1))
255
+
256
+ def set_cell(self, gr, col, text):
257
+ if not self.editable or col in self._readonly or self.row_readonly(gr) \
258
+ or not (0 <= col < self._w) or gr < self._hdr:
259
+ return False
260
+ text = str(text)
261
+ rc, n_rc, rr, n_rr = self._ro_arrays()
262
+ pre = _LIB.gc_ndata(self._core)
263
+ changed = _LIB.gc_set_cell(self._core, gr - self._hdr, col, text.encode("utf-8"),
264
+ rc, n_rc, rr, n_rr)
265
+ if not changed:
266
+ return False
267
+ if _LIB.gc_ndata(self._core) > pre: # materialised -> all columns gained a blank
268
+ self._distinct.clear()
269
+ else:
270
+ self._touch([col])
271
+ self._push_snap((gr, col, gr, col))
272
+ self._find_cache = None
273
+ self._used = None
274
+ if self._rebuilds_on_edit(gr, col):
275
+ self._rebuild()
276
+ if self.on_edit:
277
+ self.on_edit()
278
+ self.changed()
279
+ return True
280
+
281
+ def grow_cols(self, new_w):
282
+ """Widen the sheet to `new_w` columns (editing past the last column, uncapped).
283
+ The C++ core re-strides its buffer; headers gain blank trailing cells. No-op
284
+ if it already has that many. New columns start blank and are fully editable."""
285
+ if new_w <= self._w:
286
+ return
287
+ _LIB.gc_grow_cols(self._core, new_w)
288
+ for hrow in self._headers:
289
+ hrow += [""] * (new_w - len(hrow))
290
+ self._w = new_w
291
+ self._distinct.clear()
292
+ self._used = None
293
+ self.changed()
294
+
295
+ def _replay_edit(self, entry, use_new):
296
+ # base undo()/redo() dispatch 'view' vs 'edit'; a cell edit drives the C++ stack.
297
+ tgt = (ctypes.c_int * 2)()
298
+ fn = _LIB.gc_redo if use_new else _LIB.gc_undo
299
+ if not fn(self._core, tgt):
300
+ return None
301
+ self._install_filt(entry[1])
302
+ if self.on_edit:
303
+ self.on_edit()
304
+ self.changed()
305
+ return self._clamp_target(entry[2])
306
+
307
+ # ---- find -> C++ (data) + Python (header rows) ----
308
+ def find_matches(self, query, case=False, scope=None):
309
+ if not query:
310
+ self._find_cache = None
311
+ return [], False
312
+ H = self._hdr
313
+ out = []
314
+ needle = query if case else query.lower()
315
+ # header rows in Python (tiny), in grid order first
316
+ hdr_scope = None
317
+ if scope:
318
+ hdr_scope = [(r1, c1, r2, c2) for (r1, c1, r2, c2) in scope if r1 < H]
319
+ hrows = range(H) if not scope else None
320
+ for hr in (range(H) if hrows is not None else []):
321
+ for c in range(self._w):
322
+ v = self._headers[hr][c]
323
+ if v and needle in (v if case else v.lower()):
324
+ out.append((hr, c))
325
+ if scope and hdr_scope:
326
+ seen = set()
327
+ for (r1, c1, r2, c2) in hdr_scope:
328
+ for hr in range(max(0, r1), min(H, r2 + 1)):
329
+ for c in range(max(0, c1), min(self._w, c2 + 1)):
330
+ if (hr, c) in seen:
331
+ continue
332
+ seen.add((hr, c))
333
+ v = self._headers[hr][c]
334
+ if v and needle in (v if case else v.lower()):
335
+ out.append((hr, c))
336
+ # data rows in C++
337
+ nb = query.encode("utf-8")
338
+ sc = None
339
+ if scope:
340
+ flat = []
341
+ for (r1, c1, r2, c2) in scope:
342
+ if r2 < H:
343
+ continue
344
+ flat.extend((max(0, r1 - H), c1, r2 - H, c2))
345
+ sc, nsc = _iarr(flat)
346
+ else:
347
+ nsc = 0
348
+ cnt, capped = ctypes.c_int(), ctypes.c_int()
349
+ p = _LIB.gc_find(self._core, nb, len(nb), 1 if case else 0, sc, nsc // 4 if scope else 0,
350
+ self.FIND_LIMIT, ctypes.byref(cnt), ctypes.byref(capped))
351
+ raw = ctypes.string_at(p, cnt.value * 8)
352
+ data = [(raw[i] | raw[i + 1] << 8 | raw[i + 2] << 16 | raw[i + 3] << 24,
353
+ raw[i + 4] | raw[i + 5] << 8 | raw[i + 6] << 16 | raw[i + 7] << 24)
354
+ for i in range(0, len(raw), 8)]
355
+ for gr, c in data:
356
+ out.append((gr + H, c))
357
+ if scope:
358
+ out = sorted(set(out))
359
+ return out, bool(capped.value)
@@ -0,0 +1,91 @@
1
+ """Column filter/sort popup logic -- GUI-free, like FindController.
2
+
3
+ The Tk and Qt filter popups are just widgets: a value checklist, a search box
4
+ and OK/Cancel. All the actual behaviour (deferred distinct scan, per-value
5
+ checked state, search over a capped column, and the exact commit rules for
6
+ "clear vs keep exactly the checked members") lives here, driven identically by
7
+ both. The widget only renders ``rows(query)`` with a checkbox per ``checked(v)``
8
+ and forwards clicks to ``toggle`` / ``toggle_all`` and OK to ``commit``.
9
+ """
10
+
11
+
12
+ class FilterController:
13
+ def __init__(self, model, col):
14
+ self.model = model
15
+ self.col = col
16
+ self.state = None # v -> bool user toggles; None until load()
17
+ self.active = None # the column's active filter set, or None
18
+ self.preloaded = [] # distinct preview (may be capped)
19
+ self.capped = False
20
+
21
+ def load(self):
22
+ """Deferred distinct scan -- run on the next event-loop tick so opening the
23
+ popup is instant even on a 1M-row column."""
24
+ self.active = self.model._filters.get(self.col)
25
+ self.preloaded, self.capped = self.model.distinct_capped(self.col)
26
+ self.state = {v: self.checked(v) for v in self.preloaded}
27
+
28
+ def checked(self, v):
29
+ """An explicit user toggle, else the default from the active filter (all
30
+ allowed when there's no filter)."""
31
+ if self.state and v in self.state:
32
+ return self.state[v]
33
+ return self.active is None or v in self.active
34
+
35
+ def rows(self, query):
36
+ q = query.strip().lower()
37
+ if not q:
38
+ return self.preloaded
39
+ if self.capped: # search the whole column, not just the preview
40
+ return self.model.distinct_matching(self.col, q)
41
+ return [v for v in self.preloaded if q in v.lower()]
42
+
43
+ def all_on(self, rows):
44
+ return bool(rows) and all(self.checked(v) for v in rows)
45
+
46
+ def truncated(self, rows):
47
+ return len(rows) >= self.model.DISTINCT_CAP
48
+
49
+ def toggle(self, v):
50
+ self.state[v] = not self.checked(v)
51
+
52
+ def toggle_all(self, rows):
53
+ target = not self.all_on(rows)
54
+ for v in rows:
55
+ self.state[v] = target
56
+
57
+ def commit(self, query):
58
+ """Apply the popup on OK. With a search query: too-many -> "contains",
59
+ else filter TO the checked matches. Empty query: clear when everything's
60
+ checked and we truly know it's everything, else keep exactly the checked."""
61
+ query = query.strip()
62
+ if query:
63
+ rows = self.rows(query)
64
+ if self.truncated(rows):
65
+ self.model.set_text_filter(self.col, "contains", query)
66
+ else:
67
+ keep = {v for v in rows if self.checked(v)}
68
+ self.model.set_filter(self.col, keep or None)
69
+ return
70
+ known = set(self.preloaded) | set(self.state)
71
+ checked = {v for v in known if self.checked(v)}
72
+ if len(checked) == len(known) and (self.active is None or not self.capped):
73
+ self.model.set_filter(self.col, None)
74
+ else:
75
+ self.model.set_filter(self.col, checked)
76
+
77
+
78
+ if __name__ == "__main__": # headless self-check of the commit rules
79
+ from .model import GridModel
80
+ m = GridModel(["A"], [["x"], ["y"], ["x"], ["z"]])
81
+ f = FilterController(m, 0); f.load()
82
+ assert f.rows("") == ["x", "y", "z"], f.rows("")
83
+ assert f.all_on(f.rows(""))
84
+ f.toggle("y"); assert not f.checked("y")
85
+ f.commit("") # keep exactly {x, z}
86
+ assert m._filters[0] == {"x", "z"}, m._filters
87
+ f2 = FilterController(m, 0); f2.load() # everything checked -> clears
88
+ f2.toggle_all(f2.rows("")); assert f2.all_on(f2.rows(""))
89
+ # active filter present + not capped -> "all checked" clears it
90
+ f2.commit(""); assert 0 not in m._filters, m._filters
91
+ print("filter self-check ok")
fastgrid/core/find.py ADDED
@@ -0,0 +1,100 @@
1
+ """Ctrl+F find controller -- GUI-free search/navigation over model.find_matches.
2
+
3
+ Both the Tk and Qt find bars drive this identical logic; only the surrounding
4
+ widgets differ:
5
+
6
+ * highlight every matching cell (lazy, via model find-state) + a distinct
7
+ active-match marker;
8
+ * count reads "i/N" (or "i/N+" when the navigable list was capped, "No results",
9
+ or "" when the query is empty);
10
+ * Next/Prev wrap; the FIRST Enter lands on the highlighted nearest match rather
11
+ than skipping past it;
12
+ * nearest match is relative to the current cell (row-major >=);
13
+ * an optional scope (the selection, when it covers more than one cell) confines
14
+ the search and is KEPT visible while stepping (selection isn't collapsed);
15
+ * case-sensitivity toggle.
16
+
17
+ The controller talks to the host grid through a tiny surface it already exposes:
18
+ ``.active``/``.anchor``/``.sel``/``.extra`` (selection state), ``.model`` and
19
+ ``.scroll_into_view(r, c)``. Highlight repaints happen via ``model.set_find`` /
20
+ ``model.clear_find`` (which fire the model's change callback -> the grid redraws).
21
+ """
22
+ from .selection import normalize
23
+
24
+
25
+ class FindController:
26
+ def __init__(self, grid):
27
+ self.grid = grid
28
+ self.model = grid.model
29
+ self.matches = []
30
+ self.idx = -1
31
+ self.capped = False
32
+ self.case = False
33
+ self.scope = None # active scope rects, or None
34
+ self.scope_range = None # scope captured at open() (for the toggle)
35
+ self.query = ""
36
+ self.current = (1, 0) # anchor for nearest / "already on match"
37
+ self.on_count = lambda text: None # the widget sets this to update its label
38
+
39
+ @staticmethod
40
+ def _scope_of(ranges):
41
+ """A meaningful scope = the selection when it covers more than one cell."""
42
+ rngs = normalize(ranges)
43
+ multi = len(rngs) > 1 or (rngs and (rngs[0][0] != rngs[0][2]
44
+ or rngs[0][1] != rngs[0][3]))
45
+ return rngs if multi else None
46
+
47
+ def open(self, ranges):
48
+ """Enter find: capture scope from the current selection, anchor nearest on
49
+ the active cell. Returns whether a scope is available (for the widget)."""
50
+ self.scope_range = self._scope_of(ranges)
51
+ self.scope = self.scope_range
52
+ self.current = tuple(self.grid.active)
53
+ self.query = ""
54
+ self.matches, self.idx = [], -1
55
+ self.run("", navigate=False)
56
+ return self.scope_range is not None
57
+
58
+ def run(self, query, navigate=False):
59
+ self.query = query
60
+ self.matches, self.capped = self.model.find_matches(query, self.case, self.scope)
61
+ if not self.matches:
62
+ self.idx = -1
63
+ self.on_count("No results" if query else "")
64
+ self.model.set_find(query, self.case, self.scope, None)
65
+ return
66
+ self.idx = next((i for i, c in enumerate(self.matches) if c >= self.current), 0)
67
+ self._activate(navigate)
68
+
69
+ def _activate(self, navigate):
70
+ cell = self.matches[self.idx]
71
+ self.on_count("%d/%d%s" % (self.idx + 1, len(self.matches), "+" if self.capped else ""))
72
+ if navigate:
73
+ self.current = cell
74
+ if self.scope is None: # collapse selection onto the match
75
+ self.grid.active = self.grid.anchor = cell
76
+ self.grid.sel, self.grid.extra = (cell[0], cell[1], cell[0], cell[1]), []
77
+ self.grid.scroll_into_view(*cell) # scoped: keep selection, just reveal
78
+ self.model.set_find(self.query, self.case, self.scope, cell) # highlight + redraw
79
+
80
+ def step(self, delta):
81
+ if not self.matches:
82
+ self.run(self.query, navigate=True)
83
+ return
84
+ if tuple(self.current) != tuple(self.matches[self.idx]):
85
+ self._activate(True) # first Enter lands on the match
86
+ return
87
+ self.idx = (self.idx + delta) % len(self.matches)
88
+ self._activate(True)
89
+
90
+ def set_case(self, on):
91
+ self.case = on
92
+ self.run(self.query, navigate=False)
93
+
94
+ def set_scope(self, on):
95
+ self.scope = self.scope_range if on else None
96
+ self.run(self.query, navigate=False)
97
+
98
+ def close(self):
99
+ self.matches, self.idx = [], -1
100
+ self.model.clear_find()