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 +13 -0
- fastgrid/core/__init__.py +8 -0
- fastgrid/core/_gpu/surface.dll +0 -0
- fastgrid/core/_gridstore/gridcore.dll +0 -0
- fastgrid/core/coremodel.py +359 -0
- fastgrid/core/filter.py +91 -0
- fastgrid/core/find.py +100 -0
- fastgrid/core/geometry.py +341 -0
- fastgrid/core/gpu.py +1531 -0
- fastgrid/core/gridcontroller.py +367 -0
- fastgrid/core/model.py +991 -0
- fastgrid/core/paint.py +268 -0
- fastgrid/core/render.py +77 -0
- fastgrid/core/selection.py +180 -0
- fastgrid/core/theme.py +43 -0
- fastgrid/render/__init__.py +6 -0
- fastgrid/render/gpu_qt.py +247 -0
- fastgrid/render/gpu_tk.py +172 -0
- fastpygrid-0.1.0.dist-info/METADATA +146 -0
- fastpygrid-0.1.0.dist-info/RECORD +22 -0
- fastpygrid-0.1.0.dist-info/WHEEL +5 -0
- fastpygrid-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
fastgrid/core/filter.py
ADDED
|
@@ -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()
|