Source code for zooui.objects.mediaobjects.stringmediaobject
## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.
"""Strings to be displayed in the ZUI."""
import time
from typing import Any
from PySide6 import QtCore, QtGui
from PySide6.QtCore import QThread
from zooui.logger import get_logger
from .mediaobject import LoadError, MediaObject
from .mediaobjectsutils.string.textlayout import TextLayoutData
[docs]
class StringMediaObject(MediaObject): # , Thread
"""
Constructor :
StringMediaObject(media_id, scene)
Parameters :
media_id : str
scene : Scene
StringMediaObject(media_id, scene) --> None
StringMediaObject implements a hybrid rendering system for
text displayin the ZUI.
`StringMediaObject.media_id` should be of the form 'string:rrggbb:foobar',
where 'rrggbb' is a string of three two-digit hexadecimal numbers
representing the colour of the text, and 'foobar' is the string to be
displayed.
Overview:
- render Dual-mode triggered by scene.vzmoving (zoom velocity)
- moving mode: Direct QPainter.drawText() rendering for smooth zoom/pan operations
- static mode: Cached QImage rendering for optimal quality when stationary
- Automatic cache invalidation when movement starts to ensure fresh rendering
From "foobar" string QImage gets generated, from form 'string:rrggbb:foobar' hash get's
generated for cache invalidation in case string color of text content changes.
Also Qfont, QFontMetrics, text dimension (width, height), and scale are cached.
QImage cached image gets invalidated if relative_scale_diff > 1% or if
self__scene_vzmoving returns True
For direct text rendering :
- longest line index gets Pre-calculated for efficient multi-line rendering
- Font creation only when point size ≥ 1 (visible text)
- Visibility culling based on viewport size ratios
- Automatic cache validation with comprehensive invalidation triggers
"""
def __init__(self, media_id: str, scene: Any) -> None:
"""
Initialize a new StringMediaObject from the media identified by media_id,
and the parent Scene referenced by scene.
"""
MediaObject.__init__(self, media_id, scene)
self._logger = get_logger("StringMediaObject")
# Get color code 'rrggbb' from media_id string and assign it to hexcol variable
hexcol: str = self._media_id[len("string:") : len("string:rrggbb")]
# Initialize and assign QtGui.QColor which can then be passed to QtPainter.setPen
self.__color: QtGui.QColor = QtGui.QColor("#" + hexcol)
# Check if color is valid to QtGui.QColor() and if not an error is raised
if not self.__color.isValid():
raise LoadError("the supplied colour is invalid")
"""Gets to be displayed text `foobar` from media_id string and assign it
to self.__str variable.
"""
# Extract the text portion from media_id by slicing from position after 'string:rrggbb:'
# Example: 'string:FF0000:Hello World' -> 'Hello World'
self.__str: str = self._media_id[len("string:rrggbb:") :]
# Returns a list of strings, e.g., 'Hello\nWorld' -> ['Hello', 'World']
self.__lines: list[str] = self.__str.split("\n")
# Pre-calculate which line is longest to avoid sorting on every render
# max() finds the maximum value from range(len(self.__lines)) using a custom key function
# range(len(self.__lines)) generates indices: 0, 1, 2, ... for each line
# key=lambda i: len(self.__lines[i]) tells max() to compare lines by their length
# Returns the INDEX of the longest line, not the line itself
# Example: if lines = ['Hi', 'Hello', 'Hey'], this returns 1 (index of 'Hello')
self.__longest_line_idx: int = max(range(len(self.__lines)), key=lambda i: len(self.__lines[i]))
# Initialize private variables that will be used for caching optimizations
# These start as None and will store computed values when first accessed
# Stores the scale value (2^(scene.zoomlevel + object.zoomlevel))
self.__cached_scale: float | None = None
# Stores the QFont object to avoid recreating it on every render call
self.__cached_font: QtGui.QFont | None = None
# Stores the QFontMetrics object (calculates text dimensions)
self.__cached_font_metrics: QtGui.QFontMetrics | None = None
# Stores the calculated (width, height) tuple for this string at current scale
self.__cached_onscreen_size: tuple[float, float] | None = None
# Text image caching variables
# Stores the rendered text as QImage for faster rendering at high zoom
self.__cached_text_image: QtGui.QImage | None = None
# Stores the scale at which the text image was rendered
self.__cached_image_scale: float | None = None
# Stores the render mode used for the cached image
self.__cached_image_mode: int | None = None
# Stores hash of text content and color for cache invalidation
self.__cached_text_hash: int | None = None
# Track if we were previously static to detect when movement starts
self.__was_static: bool = True
# Parallel rendering support
# Stores pre-calculated layout data for parallel rendering
self.__layout_data: TextLayoutData | None = None
# Timestamp for layout cache invalidation
self.__layout_timestamp: float = 0.0
# Whether parallel rendering is enabled for this object
self.__parallel_enabled: bool = True
# Performance statistics for parallel rendering
self.__parallel_stats: dict[str, Any] = {
"layout_calculations": 0,
"cache_hits": 0,
"render_time_ms": 0.0,
"last_render_time": 0.0,
}
# Class variable: indicates this media object supports transparency
transparent: bool = True
# Class variable: point size of the font when the scale is 100%
base_pointsize: float = 24.0
def __compute_text_hash(self) -> int:
"""
Method :
StringMediaObject.__compute_text_hash()
Parameters :
None
StringMediaObject.__compute_text_hash() --> int
Compute hash of text content and color for cache invalidation.
Returns a combined hash of all text lines and the color RGB value.
"""
# Combine all lines into a single string for hashing
text_content: str = "".join(self.__lines)
color_rgb: int = self.__color.rgb()
return hash((text_content, color_rgb))
def __is_image_cache_valid(self, current_scale: float, mode: int) -> bool:
"""
Method :
StringMediaObject.__is_image_cache_valid(current_scale, mode)
Parameters :
current_scale : float
mode : int
StringMediaObject.__is_image_cache_valid(current_scale, mode) --> bool
Check if cached text image is valid for current scale and render mode.
Returns True if cache is valid, False otherwise.
"""
if self.__cached_text_image is None:
return False
if self.__cached_image_scale is None or self.__cached_image_mode is None:
return False
# Check scale - text rendering is very sensitive to scale changes
# We need exact scale match for text rendering
# Use relative difference to handle floating point precision
if self.__cached_image_scale == 0 or current_scale == 0:
return False
relative_scale_diff: float = abs(current_scale - self.__cached_image_scale) / self.__cached_image_scale
if relative_scale_diff > 0.01: # 1% tolerance
return False
# Check render mode
if mode != self.__cached_image_mode:
return False
# Check text/color hasn't changed
current_hash: int = self.__compute_text_hash()
return current_hash == self.__cached_text_hash
def __render_text_direct(self, painter: Any, x: float, y: float, mode: int) -> None:
"""
Method :
StringMediaObject.__render_text_direct(painter, x, y, mode)
Parameters :
painter : QPainter
x : float - x position to render at
y : float - y position to render at
mode : int
StringMediaObject.__render_text_direct(painter, x, y, mode) --> None
Render text directly using QPainter.drawText().
This is the original rendering method used before caching was implemented.
"""
# Get dimensions
onscreen_w: float
onscreen_h: float
onscreen_w, onscreen_h = self.onscreen_size
if onscreen_w <= 0 or onscreen_h <= 0:
return
# Set pen color for text
painter.setPen(self.__color)
# Get font
font: QtGui.QFont | None = self.__font
if not font:
return
painter.setFont(font)
# Get font metrics
fontmetrics: QtGui.QFontMetrics | None = self.__cached_font_metrics
if fontmetrics is None:
return
fontmetrics_nonnull: QtGui.QFontMetrics = fontmetrics
hl: int = fontmetrics_nonnull.height()
# Render multiline or single line text
if len(self.__lines) > 1:
yr: float = y
line: str
for line in self.__lines:
rect: QtCore.QRectF = QtCore.QRectF(int(x), int(yr), int(onscreen_w), int(hl))
painter.drawText(rect, line)
yr += hl
else:
rect: QtCore.QRectF = QtCore.QRectF(int(x), int(y), int(onscreen_w), int(hl)) # type: ignore[no-redef]
painter.drawText(rect, self.__lines[0])
def __render_text_to_image(self, mode: int) -> QtGui.QImage:
"""
Method :
StringMediaObject.__render_text_to_image(mode)
Parameters :
mode : int
StringMediaObject.__render_text_to_image(mode) --> QImage
Render text to QImage for caching.
Creates a transparent image with the text rendered at current scale.
"""
# Get dimensions
onscreen_w: float
onscreen_h: float
onscreen_w, onscreen_h = self.onscreen_size
if onscreen_w <= 0 or onscreen_h <= 0:
return QtGui.QImage()
# Create transparent image with appropriate size
image: QtGui.QImage = QtGui.QImage(
int(onscreen_w),
int(onscreen_h),
QtGui.QImage.Format.Format_ARGB8565_Premultiplied, # Format_ARGB32_Premultiplied
)
image.fill(QtCore.Qt.GlobalColor.transparent)
# Create painter for image
painter: QtGui.QPainter = QtGui.QPainter(image)
# painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
# painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing)
# Render text to image using direct rendering logic at position (0, 0)
self.__render_text_direct(painter, 0, 0, mode)
painter.end()
return image
[docs]
def invalidate_cache(self) -> None:
"""
Method :
StringMediaObject.invalidate_cache()
Parameters :
None
StringMediaObject.invalidate_cache() --> None
Invalidate text image cache.
Clears all cached image data to force re-rendering on next render call.
"""
self.__cached_text_image = None
self.__cached_image_scale = None
self.__cached_image_mode = None
self.__cached_text_hash = None
self.__cached_onscreen_size = None
# Also invalidate parallel rendering cache
self.__layout_data = None
self.__layout_timestamp = 0.0
# Parallel rendering helper methods
[docs]
def _get_text(self) -> str:
"""Get the text content for parallel rendering.
Returns:
The text string to render
"""
return self.__str
[docs]
def _get_font(self) -> dict:
"""Get the font properties for parallel rendering (thread-safe).
Returns plain Python data (dict) instead of a QFont object so
that this method can be safely called from any thread.
QtGui.QFont construction is deferred to TextLayoutData.to_qfont()
which runs on the main thread during rendering.
Returns:
dict with keys: family, pointsize, weight, italic
"""
pointsize = max(self.base_pointsize * self.scale, 1.0)
return {
"family": "Sans Serif",
"pointsize": pointsize,
"weight": QtGui.QFont.Normal,
"italic": False,
}
[docs]
def _get_color(self) -> QtGui.QColor:
"""Get the text color for parallel rendering.
Returns:
QColor object for text color
"""
return self.__color
[docs]
def precalculate_layout(self, viewport_rect: QtCore.QRectF) -> TextLayoutData | None:
"""Pre-calculate layout data for parallel rendering.
Args:
viewport_rect: Current viewport rectangle
Returns:
TextLayoutData if successful, None otherwise
"""
try:
start_time = time.time()
# Check if we have valid cached layout data
if self.__layout_data and self.__layout_data.is_valid and not self.__layout_data.is_stale():
self.__parallel_stats["cache_hits"] += 1
return self.__layout_data
# Calculate new layout data
self.__layout_data = TextLayoutData.from_string_object(self, viewport_rect)
self.__layout_timestamp = time.time()
self.__parallel_stats["layout_calculations"] += 1
processing_time = (time.time() - start_time) * 1000
self.__parallel_stats["render_time_ms"] += processing_time
self.__parallel_stats["last_render_time"] = processing_time
return self.__layout_data
except Exception as e:
self._logger.error("Error precalculating layout: %s", e)
return None
[docs]
def render_with_layout(self, painter: QtGui.QPainter, layout_data: TextLayoutData) -> bool:
"""Render text using pre-calculated layout data.
Args:
painter: QPainter object to render with
layout_data: Pre-calculated TextLayoutData
Returns:
True if rendering successful, False otherwise
"""
try:
start_time = time.time()
# Render using layout data
layout_data.render(painter)
processing_time = (time.time() - start_time) * 1000
self.__parallel_stats["render_time_ms"] += processing_time
self.__parallel_stats["last_render_time"] = processing_time
return True
except Exception as e:
self._logger.error("Error rendering with layout: %s", e)
return False
[docs]
def get_parallel_stats(self) -> dict[str, Any]:
"""Get parallel rendering statistics.
Returns:
Dictionary with parallel rendering statistics
"""
stats = self.__parallel_stats.copy()
stats["has_layout_cache"] = self.__layout_data is not None
stats["layout_cache_valid"] = (
self.__layout_data is not None and self.__layout_data.is_valid and not self.__layout_data.is_stale()
)
stats["parallel_enabled"] = self.__parallel_enabled
return stats
[docs]
def enable_parallel_rendering(self, enabled: bool = True) -> None:
"""Enable or disable parallel rendering for this object.
Args:
enabled: Whether to enable parallel rendering
"""
self.__parallel_enabled = enabled
if not enabled:
# Clear layout cache when disabling
self.__layout_data = None
self.__layout_timestamp = 0.0
[docs]
def render(self, painter: Any, mode: int) -> None:
"""
Method :
StringMediaObject.render(painter, mode)
Parameters :
painter : QPainter
mode : int
StringMediaObject.render(painter, mode) --> None
Given QPainter and Rendering mode renders the string calculating the
rendering rectangle and using QtPainter.drawText
Note: Size visibility is checked by the scene via is_size_visible().
"""
# Get top-left corner position of the text object on scremoen
x: float
y: float
x, y = self.topleft
# Hybrid rendering: use parallel/direct rendering while scene is moving, cached images when static
if self._scene.vzmoving:
# Scene is zooming - use parallel or direct rendering for smoothness
# Invalidate cache when zooming starts to ensure fresh cache when movement stops
if self.__was_static:
self.invalidate_cache()
self.__was_static = False
# Try parallel rendering if enabled and layout data is available
if (
self.__parallel_enabled
and self.__layout_data
and self.__layout_data.is_valid
and not self.__layout_data.is_stale()
):
# Use pre-calculated layout for rendering
self.render_with_layout(painter, self.__layout_data)
else:
# Fall back to direct rendering
self.__render_text_direct(painter, x, y, mode)
else:
# Scene is static - use cached images for optimal rendering quality
self.__was_static = True
current_scale: float = self.scale
if self.__is_image_cache_valid(current_scale, mode):
# Draw cached image
painter.drawImage(int(x), int(y), self.__cached_text_image)
else:
# Render text to image, cache it, then draw
self.__cached_text_image = self.__render_text_to_image(mode)
self.__cached_image_scale = current_scale
self.__cached_image_mode = mode
self.__cached_text_hash = self.__compute_text_hash()
# Draw the newly cached image
if self.__cached_text_image and not self.__cached_text_image.isNull():
painter.drawImage(int(x), int(y), self.__cached_text_image)
[docs]
def is_size_visible(self, mode: int) -> bool:
"""
String-specific size visibility check.
Returns False if text is too small or too large to be visible,
otherwise returns super().is_size_visible(mode).
"""
if not super().is_size_visible(mode):
return False
w, h = self.onscreen_size
viewport_w, viewport_h = self._scene.viewport_size
# String-specific thresholds (from current render() method)
not_too_small = min(w, h) > int((min(viewport_w, viewport_h)) / 48)
not_too_large = max(w, h) < int((max(viewport_w, viewport_h)) / 1)
return not_too_small and not_too_large
@property
def __pointsize(self) -> float:
"""
Property :
__pointsize
Parameters :
None
__pointsize --> float
Calculate and return the font point size based on the current scale.
Returns base_pointsize multiplied by the current scale factor.
"""
return self.base_pointsize * self.scale
@property
def __font(self) -> QtGui.QFont | None:
"""
Property :
__font
Parameters :
None
__font --> QFont or None
Create and return a QFont object with the appropriate point size
for the current scale.
Returns minimum font (1.0 point) if the point size is less than 1.
Otherwise returns a Sans Serif font with the calculated point size.
Uses caching to avoid recreating font objects on every access.
"""
current_scale: float = self.scale
# Return cached font if scale hasn't changed
if self.__cached_scale == current_scale and self.__cached_font is not None:
return self.__cached_font
# Guard: QFont/QFontMetrics must only be constructed on the main thread.
# Constructing them from worker threads causes C++-level races in Qt's
# font database and glyph caches, leading to SIGSEGV crashes.
app = QtCore.QCoreApplication.instance()
if app and app.thread() != QThread.currentThread():
self._logger.warning("__font accessed from non-main thread; QFont construction deferred to avoid crash")
return self.__cached_font
pointsize: float = self.__pointsize
# Apply minimum font size (1.0 points) when too small
if pointsize < 1:
pointsize = 1.0 # Minimum visible font size
font: QtGui.QFont = QtGui.QFont("Sans Serif")
font.setPointSizeF(pointsize)
# Cache the font and metrics
self.__cached_scale = current_scale
self.__cached_font = font
self.__cached_font_metrics = QtGui.QFontMetrics(font)
return font
@property
def onscreen_size(self) -> tuple[float, float]:
"""
Property :
StringMediaObject.onscreen_size
Parameters :
None
StringMediaObject.onscreen_size --> Tuple[float, float]
Returns width and height of the MediaObject passed to the StringMediaObject Class.
Uses caching to avoid recalculating size on every access.
"""
current_scale: float = self.scale
# Return cached size if scale hasn't changed
if self.__cached_scale == current_scale and self.__cached_onscreen_size is not None:
return self.__cached_onscreen_size
font: QtGui.QFont | None = self.__font
if font:
# Use cached font metrics instead of creating new ones
fontmetrics: QtGui.QFontMetrics | None = self.__cached_font_metrics
if fontmetrics is None:
# Fallback to minimum size estimation
w = max(len(self.__str) * 5, 10.0) # Estimate 5px per char, min 10px
h = max(len(self.__lines) * 10, 10.0) # Estimate 10px per line, min 10px
self.__cached_onscreen_size = (w, h)
return (w, h)
# Type assertion: fontmetrics is not None after the check above
fontmetrics_nonnull: QtGui.QFontMetrics = fontmetrics
w: float # type: ignore[no-redef]
h: float # type: ignore[no-redef]
if len(self.__lines) > 1:
# Use cached longest line index instead of sorting every time
longest_line: str = self.__lines[self.__longest_line_idx]
w = fontmetrics_nonnull.horizontalAdvance(longest_line + "-------")
h = fontmetrics_nonnull.height() * len(self.__lines)
else:
w = fontmetrics_nonnull.horizontalAdvance(self.__str + "-")
h = fontmetrics_nonnull.height()
self.__cached_onscreen_size = (w, h)
return (w, h)
else:
self.__cached_onscreen_size = (0, 0)
return (0, 0)
@property
def lines(self) -> list[str]:
"""
Property :
lines
Parameters :
None
lines --> list[str]
Get the list of text lines.
"""
return self.__lines
@lines.setter
def lines(self, new_lines: list[str]) -> None:
"""
Setter :
lines
Parameters :
new_lines : list[str]
lines = new_lines --> None
Set new text lines and update internal state.
Updates the longest line index and invalidates caches.
"""
self.__lines = new_lines
# Update longest line index
if self.__lines:
self.__longest_line_idx = max(range(len(self.__lines)), key=lambda i: len(self.__lines[i]))
else:
self.__longest_line_idx = 0
# Invalidate caches since text content changed
self.invalidate_cache()