|
46 | 46 | import math |
47 | 47 | import os |
48 | 48 | import struct |
| 49 | +from collections import OrderedDict |
49 | 50 |
|
50 | 51 | from qtpy.QtCore import QObject, QRectF, QSize, QSizeF, Qt |
51 | 52 | from qtpy.QtGui import ( |
@@ -208,67 +209,50 @@ def draw(self, painter, rect, flags, text): |
208 | 209 | pass |
209 | 210 |
|
210 | 211 |
|
211 | | -ASCENTCACHE = {} |
| 212 | +ASCENTCACHE = OrderedDict() |
212 | 213 |
|
213 | | -# Module-level cache: ``id(font) -> tuple_key`` (fast path) and |
214 | | -# ``tuple_key -> tuple_key`` (slow path). The tuple key is built from a |
215 | | -# handful of QFont attributes that uniquely identify the *logical* font for |
216 | | -# metrics purposes. Tick-rendering uses very few distinct fonts in practice |
217 | | -# so both dicts stay tiny. |
218 | | -# |
219 | | -# This replaces the previous ``id(font) -> font.key()`` design. Two reasons: |
| 214 | +_FM_CACHE_LIMIT = 256 # max QFontMetrics / QFontMetricsF / ascent entries |
| 215 | + |
| 216 | + |
| 217 | +# Single-slot, leak-free memo for ``QFont.key()``. |
220 | 218 | # |
221 | | -# 1. ``QFont.key()`` is a sip dispatch that costs ~3.3 us/call on PyQt5 and |
222 | | -# ~9.3 us/call on PyQt6 -- it became the single biggest residual hotspot |
223 | | -# in ``QwtText.textSize`` on PyQt6. |
224 | | -# 2. PyQt6 returns a fresh Python wrapper around the same QFont on most |
225 | | -# calls, so ``id(font)`` changes between calls and the id-keyed fast path |
226 | | -# misses ~92% of the time. The tuple-key second level recovers the hits |
227 | | -# those misses would have produced, without paying for ``font.key()``. |
| 219 | +# ``QFont.key()`` is the only correct, binding-stable identity for metrics |
| 220 | +# caching, but it is a relatively costly sip call (~1 us on PyQt5, ~1.6 us on |
| 221 | +# PyQt6) and it is queried once per tick label. The previous implementation |
| 222 | +# memoized it in an ``id(font) -> (font, key)`` dict which kept a *strong |
| 223 | +# reference* to every distinct QFont wrapper it saw (up to 1024). That is a |
| 224 | +# genuine memory / GDI-handle leak that the bounded ``.clear()`` only masked |
| 225 | +# (see the issue #93 segfault regression). |
228 | 226 | # |
229 | | -# The tuple key uses ``(family, pixelSize-or-pointSizeF, weight, italic, |
230 | | -# stretch, styleStrategy)``. This is what determines ``QFontMetrics`` output |
231 | | -# in practice; if two QFonts share these values they share metrics. |
232 | | - |
233 | | -_FONT_KEY_CACHE: dict = {} # id(font) -> tuple_key (fast path) |
234 | | -_FONT_TUPLE_CACHE: dict = {} # tuple_key -> tuple_key (interning, also acts |
235 | | -# as the "have we seen this logical font" set) |
236 | | -_FONT_KEY_CACHE_LIMIT = 1024 |
237 | | -_FM_CACHE_LIMIT = 256 # max QFontMetrics/QFontMetricsF entries per engine |
238 | | - |
239 | | - |
240 | | -def _font_tuple_key(font): |
241 | | - """Build a hashable tuple identifying the logical font.""" |
242 | | - px = font.pixelSize() |
243 | | - return ( |
244 | | - font.family(), |
245 | | - px if px > 0 else font.pointSizeF(), |
246 | | - font.weight(), |
247 | | - font.italic(), |
248 | | - font.stretch(), |
249 | | - font.styleStrategy(), |
250 | | - ) |
| 227 | +# This single-slot memo keeps at most ONE QFont alive (bounded => not a leak) |
| 228 | +# while still skipping the repeated ``font.key()`` call for the very common |
| 229 | +# case of the same painter font being reused across an entire scale-draw pass. |
| 230 | +# Retaining that one font is precisely what makes the ``id()`` comparison safe |
| 231 | +# against id reuse. On a miss the real ``font.key()`` is recomputed, so the |
| 232 | +# result is always lossless and correct. |
| 233 | +_LAST_FONT = None |
| 234 | +_LAST_FONT_ID = None |
| 235 | +_LAST_FONT_KEY = None |
251 | 236 |
|
252 | 237 |
|
253 | 238 | def font_key_cached(font): |
254 | | - """Return a hashable cache key uniquely identifying ``font`` for metrics. |
| 239 | + """Return ``font.key()`` using a single-slot, leak-free memo. |
255 | 240 |
|
256 | | - The returned value is **not** ``QFont.key()`` -- it is a tuple computed |
257 | | - from a handful of QFont attributes. It is safe to use as a dict key for |
258 | | - metrics caches (callers in this module always compare by ``==`` only). |
| 241 | + The returned value is the genuine ``QFont.key()`` string and is safe to use |
| 242 | + as a dict key for metrics caches. At most one ``QFont`` is ever retained. |
| 243 | +
|
| 244 | + :param QFont font: Font |
| 245 | + :return: ``font.key()`` |
259 | 246 | """ |
| 247 | + global _LAST_FONT, _LAST_FONT_ID, _LAST_FONT_KEY |
260 | 248 | fid = id(font) |
261 | | - entry = _FONT_KEY_CACHE.get(fid) |
262 | | - if entry is not None: |
263 | | - return entry[1] |
264 | | - tkey = _font_tuple_key(font) |
265 | | - # Intern: reuse the same tuple object across all id() variants so dict |
266 | | - # lookups in caller-side caches benefit from object-identity hash hits. |
267 | | - interned = _FONT_TUPLE_CACHE.setdefault(tkey, tkey) |
268 | | - if len(_FONT_KEY_CACHE) >= _FONT_KEY_CACHE_LIMIT: |
269 | | - _FONT_KEY_CACHE.clear() |
270 | | - _FONT_KEY_CACHE[fid] = (font, interned) |
271 | | - return interned |
| 249 | + if fid == _LAST_FONT_ID: |
| 250 | + return _LAST_FONT_KEY |
| 251 | + key = font.key() |
| 252 | + _LAST_FONT = font |
| 253 | + _LAST_FONT_ID = fid |
| 254 | + _LAST_FONT_KEY = key |
| 255 | + return key |
272 | 256 |
|
273 | 257 |
|
274 | 258 | def get_screen_resolution(): |
@@ -305,31 +289,31 @@ class QwtPlainTextEngine(QwtTextEngine): |
305 | 289 |
|
306 | 290 | def __init__(self): |
307 | 291 | self.qrectf_max = QRectF(0, 0, QWIDGETSIZE_MAX, QWIDGETSIZE_MAX) |
308 | | - self._fm_cache = {} |
309 | | - self._fm_cache_f = {} |
310 | | - self._margins_cache = {} |
311 | | - # Fast path: when textMargins is called repeatedly with the same |
312 | | - # QFont instance, skip the (expensive) font.key() Qt call. |
313 | | - self._margins_last_id = -1 |
314 | | - self._margins_last_value = None |
| 292 | + self._fm_cache = OrderedDict() |
| 293 | + self._fm_cache_f = OrderedDict() |
| 294 | + self._margins_cache = OrderedDict() |
315 | 295 |
|
316 | 296 | def fontmetrics(self, font): |
317 | 297 | fid = font_key_cached(font) |
318 | 298 | try: |
319 | 299 | return self._fm_cache[fid] |
320 | 300 | except KeyError: |
321 | 301 | if len(self._fm_cache) >= _FM_CACHE_LIMIT: |
322 | | - self._fm_cache.clear() |
323 | | - return self._fm_cache.setdefault(fid, QFontMetrics(font)) |
| 302 | + self._fm_cache.popitem(last=False) |
| 303 | + fm = QFontMetrics(font) |
| 304 | + self._fm_cache[fid] = fm |
| 305 | + return fm |
324 | 306 |
|
325 | 307 | def fontmetrics_f(self, font): |
326 | 308 | fid = font_key_cached(font) |
327 | 309 | try: |
328 | 310 | return self._fm_cache_f[fid] |
329 | 311 | except KeyError: |
330 | 312 | if len(self._fm_cache_f) >= _FM_CACHE_LIMIT: |
331 | | - self._fm_cache_f.clear() |
332 | | - return self._fm_cache_f.setdefault(fid, QFontMetricsF(font)) |
| 313 | + self._fm_cache_f.popitem(last=False) |
| 314 | + fm = QFontMetricsF(font) |
| 315 | + self._fm_cache_f[fid] = fm |
| 316 | + return fm |
333 | 317 |
|
334 | 318 | def heightForWidth(self, font, flags, text, width): |
335 | 319 | """ |
@@ -359,14 +343,15 @@ def textSize(self, font, flags, text): |
359 | 343 | return rect.size() |
360 | 344 |
|
361 | 345 | def effectiveAscent(self, font): |
362 | | - global ASCENTCACHE |
363 | 346 | fontKey = font_key_cached(font) |
364 | 347 | ascent = ASCENTCACHE.get(fontKey) |
365 | 348 | if ascent is not None: |
366 | 349 | return ascent |
367 | 350 | if len(ASCENTCACHE) >= _FM_CACHE_LIMIT: |
368 | | - ASCENTCACHE.clear() |
369 | | - return ASCENTCACHE.setdefault(fontKey, self.findAscent(font)) |
| 351 | + ASCENTCACHE.popitem(last=False) |
| 352 | + ascent = self.findAscent(font) |
| 353 | + ASCENTCACHE[fontKey] = ascent |
| 354 | + return ascent |
370 | 355 |
|
371 | 356 | def findAscent(self, font): |
372 | 357 | dummy = "E" |
@@ -409,20 +394,14 @@ def textMargins(self, font): |
409 | 394 | :param QFont font: Font of the text |
410 | 395 | :return: tuple (left, right, top, bottom) representing margins |
411 | 396 | """ |
412 | | - # Fast path: same QFont object as the previous call. |
413 | | - font_id = id(font) |
414 | | - if font_id == self._margins_last_id: |
415 | | - return self._margins_last_value |
416 | 397 | fkey = font_key_cached(font) |
417 | 398 | cached = self._margins_cache.get(fkey) |
418 | 399 | if cached is None: |
419 | 400 | fm = self.fontmetrics(font) |
420 | 401 | cached = (0, 0, fm.ascent() - self.effectiveAscent(font), fm.descent()) |
421 | 402 | if len(self._margins_cache) >= _FM_CACHE_LIMIT: |
422 | | - self._margins_cache.clear() |
| 403 | + self._margins_cache.popitem(last=False) |
423 | 404 | self._margins_cache[fkey] = cached |
424 | | - self._margins_last_id = font_id |
425 | | - self._margins_last_value = cached |
426 | 405 | return cached |
427 | 406 |
|
428 | 407 | def draw(self, painter, rect, flags, text): |
@@ -588,12 +567,10 @@ class QwtText_LayoutCache(object): |
588 | 567 | def __init__(self): |
589 | 568 | self.textSize = None |
590 | 569 | self.fontKey = None |
591 | | - self.fontId = -1 |
592 | 570 |
|
593 | 571 | def invalidate(self): |
594 | 572 | self.textSize = None |
595 | 573 | self.fontKey = None |
596 | | - self.fontId = -1 |
597 | 574 |
|
598 | 575 |
|
599 | 576 | class QwtText(object): |
@@ -1108,22 +1085,17 @@ def textSize(self, defaultFont): |
1108 | 1085 | """ |
1109 | 1086 | font = self.usedFont(defaultFont) |
1110 | 1087 | cache = self.__layoutCache |
1111 | | - font_id = id(font) |
1112 | | - if cache.textSize is not None and cache.fontId == font_id: |
1113 | | - sz = QSizeF(cache.textSize) |
1114 | | - else: |
1115 | | - fkey = font_key_cached(font) |
1116 | | - if ( |
1117 | | - cache.textSize is None |
1118 | | - or not cache.textSize.isValid() |
1119 | | - or cache.fontKey != fkey |
1120 | | - ): |
1121 | | - cache.textSize = self.__data.textEngine.textSize( |
1122 | | - font, self.__data.renderFlags, self.__data.text |
1123 | | - ) |
1124 | | - cache.fontKey = fkey |
1125 | | - cache.fontId = font_id |
1126 | | - sz = QSizeF(cache.textSize) |
| 1088 | + fkey = font_key_cached(font) |
| 1089 | + if ( |
| 1090 | + cache.textSize is None |
| 1091 | + or not cache.textSize.isValid() |
| 1092 | + or cache.fontKey != fkey |
| 1093 | + ): |
| 1094 | + cache.textSize = self.__data.textEngine.textSize( |
| 1095 | + font, self.__data.renderFlags, self.__data.text |
| 1096 | + ) |
| 1097 | + cache.fontKey = fkey |
| 1098 | + sz = QSizeF(cache.textSize) |
1127 | 1099 | if self.__data.layoutAttributes & self.MinimumLayout: |
1128 | 1100 | (left, right, top, bottom) = self.__data.textEngine.textMargins(font) |
1129 | 1101 | sz -= QSizeF(left + right, top + bottom) |
|
0 commit comments