Source code for zooui.windows.dialogwindows.svgpickerinputdialog
## 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/>.
"""SVG picker input dialog with color selection."""
import os
import xml.etree.ElementTree as ET
from collections import deque
from typing import TYPE_CHECKING, Any
from PySide6 import QtSvg
from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QBrush, QColor, QKeySequence, QPainter, QPen, QShortcut
from PySide6.QtWidgets import (
QColorDialog,
QDialog,
QDialogButtonBox,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from zooui.logger import get_logger
from zooui.objects.mediaobjects.mediaobjectsutils.svg.svgcache.svgcache import get_svg_cache
from zooui.utils._packaging import data_dir
from zooui.utils._xdg import get_colorstore_dir
if TYPE_CHECKING:
from PySide6.QtGui import QPaintEvent
# Type aliases
ColorCode = str
DialogResult = tuple[bool, str]
[docs]
class OpenSVGPickerInputDialog:
"""
Constructor :
OpenSVGPickerInputDialog()
Parameters :
None
OpenSVGPickerInputDialog() --> None
Gather SVG file selection through a dialog with color selection.
Also gives a selection column of the last 20 used colors.
"""
# All available SVG files (populated in __init__)
SVG_FILES: list[str] = []
# SVG display names (filename without extension)
SVG_NAMES: dict[str, str] = {}
# Default SVG thickness
DEFAULT_SVG_THICKNESS: str = "50"
[docs]
def __init__(self) -> None:
"""
Constructor :
OpenSVGPickerInputDialog()
Parameters :
None
OpenSVGPickerInputDialog() --> None
Create a new OpenSVGPickerInputDialog for gathering SVG file selection with color selection.
Initializes the dialog with empty color, loads previously used colors
from the color store file, or creates default colors (red, green, blue) if
no color history exists. Also scans the SVG directory for available SVG files.
"""
self.shape_color: str = ""
self.selected_svg: str = ""
self.__logger = get_logger("OpenSVGPickerInputDialog")
self.color_codes: deque[ColorCode] = deque(maxlen=24)
self.custom_color_input: Any = None # Will be QLineEdit if needed
self.thickness_input: Any = None # Will be QLineEdit for thickness
self._svg_buttons: dict[str, QPushButton] = {}
self._svg_colors: dict[str, str] = {} # Store color for each SVG
self._svg_thicknesses: dict[str, str] = {} # Store thickness for each SVG
self._svg_contents: dict[str, str] = {} # Store original SVG content
self._svg_renderers: dict[str, QtSvg.QSvgRenderer] = {} # Store renderers
self._modified_svg_files: dict[str, str] = {} # Store cache hashes of modified SVG files
# Scan SVG directory
self.SVG_FILES = []
self.SVG_NAMES = {}
svg_dir = os.path.join(data_dir(), "data", "SVG")
if os.path.isdir(svg_dir):
for filename in os.listdir(svg_dir):
if filename.lower().endswith(".svg"):
filepath = os.path.join(svg_dir, filename)
self.SVG_FILES.append(filepath)
self.SVG_NAMES[filepath] = filename[:-4] # remove .svg extension
# Initialize default thickness for all SVGs
for svg_path in self.SVG_FILES:
self._svg_thicknesses[svg_path] = self.DEFAULT_SVG_THICKNESS
# Initialize default color (white) for all SVGs
for svg_path in self.SVG_FILES:
self._svg_colors[svg_path] = "ffffff"
# Initialize SVG cache
self._svg_cache = get_svg_cache()
self.color_dir = str(get_colorstore_dir())
if os.path.isfile(self.color_dir + "/color_list.txt"):
with open(self.color_dir + "/color_list.txt") as f:
for line in f:
stripline = line.strip()
stripline = stripline.lower()
if len(stripline) == 6 and stripline not in self.color_codes:
self.color_codes.append(stripline)
else:
if os.path.isdir(self.color_dir):
f = open(self.color_dir + "/color_list.txt", "w")
self.color_codes.append("ffffff")
f.write("ffffff\n")
self.color_codes.append("ff0000")
f.write("ff0000\n")
self.color_codes.append("00ff00")
f.write("00ff00\n")
self.color_codes.append("0000ff")
f.write("0000ff\n")
f.close()
else:
os.mkdir(self.color_dir)
f = open(self.color_dir + "/color_list.txt", "w")
self.color_codes.append("ffffff")
f.write("ffffff\n")
self.color_codes.append("ff0000")
f.write("ff0000\n")
self.color_codes.append("00ff00")
f.write("00ff00\n")
self.color_codes.append("0000ff")
f.write("0000ff\n")
f.close()
[docs]
def _modify_svg_file(self, svg_path: str, color: str | None = None, thickness: str | None = None) -> str:
"""
Modify an SVG file with new color and/or thickness using XML parsing.
Args:
svg_path: Path to the original SVG file
color: Hex color code (without #) to apply to stroke and fill
thickness: Stroke width value
Returns:
Cache hash of modified SVG (format: svg_{8_char_hex})
"""
try:
# Parse the SVG file
tree = ET.parse(svg_path)
root = tree.getroot()
# Define SVG namespace
ns = {"svg": "http://www.w3.org/2000/svg"}
# Find all elements that can have stroke and fill attributes
# This includes: path, rect, circle, ellipse, line, polyline, polygon
elements = []
for tag in ["path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "g"]:
elements.extend(root.findall(f".//svg:{tag}", ns) or [])
# Also include the root svg element itself (for color only, not stroke-width)
elements.append(root)
for elem in elements:
# Apply color if specified
if color:
color_hex = f"#{color}"
# Update stroke attribute if it exists and is not "none"
stroke = elem.get("stroke")
if stroke and stroke.lower() not in ("none", "transparent"):
elem.set("stroke", color_hex)
elif "stroke" in elem.attrib and elem.get("stroke") == "none":
# Keep as "none" - don't add color to outlines that should be invisible
pass
elif (
elem.tag.endswith("path")
or elem.tag.endswith("rect")
or elem.tag.endswith("circle")
or elem.tag.endswith("ellipse")
or elem.tag.endswith("line")
or elem.tag.endswith("polyline")
or elem.tag.endswith("polygon")
):
# For shape elements without stroke attribute, add it
elem.set("stroke", color_hex)
# Update fill attribute if it exists
fill = elem.get("fill")
if fill is not None:
# Only update if not "none" or "transparent"
if fill.lower() not in ("none", "transparent"):
elem.set("fill", color_hex)
# Note: we don't add fill to elements that don't have it
# to avoid filling outlines that should remain empty
# Apply thickness if specified
# Don't apply stroke-width to root svg element, only to shape elements
if thickness and elem != root:
# Update stroke-width attribute
elem.set("stroke-width", thickness)
# Convert to string and store in cache
svg_content = ET.tostring(root, encoding="utf-8").decode("utf-8")
cache_hash = self._svg_cache.store_svg(svg_content)
return cache_hash
except Exception as e:
# If XML parsing fails, re-raise the exception
self.__logger.error("Error modifying SVG file %s: %s", svg_path, e)
raise
[docs]
def _color_square(self, color_code: ColorCode) -> QWidget:
"""
Method :
OpenSVGPickerInputDialog._color_square(color_code)
Parameters :
color_code : str
OpenSVGPickerInputDialog._color_square(color_code) --> QWidget
Creates a colored square widget.
"""
color_square = QWidget()
color = QColor("#" + str(color_code))
color_square.setFixedSize(20, 20)
def paintEvent(event: "QPaintEvent") -> None:
painter = QPainter(color_square)
painter.fillRect(color_square.rect(), color)
# Explicit .end() required: an unended QPainter corrupts
# Qt's C++ paint engine, ultimately causing SIGSEGV.
painter.end()
color_square.paintEvent = paintEvent
return color_square
[docs]
def _color_button_click(self, color: ColorCode) -> None:
"""
Method :
OpenSVGPickerInputDialog._color_button_click(color)
Parameters :
color : str
OpenSVGPickerInputDialog._color_button_click(color) --> None
Handles color button click event.
"""
self.shape_color = color
[docs]
def _color_button(self, color_code: ColorCode) -> QWidget:
"""
Method :
OpenSVGPickerInputDialog._color_button(color_code)
Parameters :
color_code : str
OpenSVGPickerInputDialog._color_button(color_code) --> QWidget
Creates a color selection button.
"""
color_widget = QWidget()
layout = QHBoxLayout()
layout.setContentsMargins(5, 2, 5, 2)
layout.setSpacing(10)
color_square = self._color_square(color_code)
label = QLabel(color_code)
# Create a QPushButton but use a QWidget wrapper to hold square + label
button = QPushButton()
button.setLayout(layout)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
# Add widget and label to the layout inside the button
layout.addWidget(color_square)
layout.addWidget(label)
layout.addStretch()
# Make the whole widget act like a button by forwarding clicks
button.clicked.connect(lambda: self._color_button_click(color_code))
# Our main layout for this widget is the button only
main_layout = QHBoxLayout(color_widget)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(button)
return color_widget
[docs]
def _svg_widget(self, svg_path: str) -> QWidget:
"""
Create a widget that displays an SVG preview and acts as a button.
"""
widget = QWidget()
# Increased size to accommodate larger preview and label
widget.setFixedSize(140, 140)
# Create a button that will fill most of the widget (leaving space for label)
button = QPushButton(widget)
button.setFixedSize(140, 120) # 120 height leaves 20 for label
button.setFlat(True) # Remove default button styling
button.setStyleSheet("background-color: transparent; border: none;") # Make transparent
self._svg_buttons[svg_path] = button
# Load and store original SVG content
with open(svg_path) as f:
self._svg_contents[svg_path] = f.read()
# Create initial renderer with default color and thickness applied
renderer = self._create_modified_svg_renderer(svg_path)
# Create a custom paint event for the button to draw the SVG
#
# NOTE: painter.end() at the bottom is required. An unended
# QPainter corrupts Qt's C++ paint engine state, which can
# trigger a SIGSEGV crash after the app runs for many hours.
def paintEvent(arg__1: "QPaintEvent") -> None:
painter = QPainter(button)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Clear background with dark gray for better visibility of white outlines
painter.fillRect(button.rect(), QColor("#404040"))
# Draw SVG centered and scaled to fit button
# Use colored renderer if available, otherwise use original
current_renderer = self._svg_renderers.get(svg_path, renderer)
if current_renderer.isValid():
# Calculate scaling to fit within 80x80 area (further reduced to prevent overflow)
svg_size = current_renderer.defaultSize()
if svg_size.width() > 0 and svg_size.height() > 0:
target_size = 80 # Further reduced to ensure no overflow
scale = min(target_size / svg_size.width(), target_size / svg_size.height())
scaled_width = svg_size.width() * scale
scaled_height = svg_size.height() * scale
x_offset = (140 - scaled_width) / 2
y_offset = (120 - scaled_height) / 2 # Center in button area (120 height)
# Set clipping region to prevent overflow (10px margins for safety)
# painter.setClipRect(QRectF(4, 4, 130, 110))
# Render the SVG (already colored if apply was clicked)
current_renderer.render(painter, QRectF(x_offset, y_offset, scaled_width, scaled_height))
else:
# Draw placeholder for zero-dimension SVG
painter.setPen(QPen(QColor("#ff0000"), 2))
painter.setBrush(QBrush(QColor("#ffcccc")))
painter.drawRect(30, 20, 80, 80)
painter.drawText(QRectF(30, 20, 80, 80), Qt.AlignmentFlag.AlignCenter, "Invalid SVG")
else:
# Draw placeholder for invalid SVG
painter.setPen(QPen(QColor("#ff0000"), 2))
painter.setBrush(QBrush(QColor("#ffcccc")))
painter.drawRect(30, 20, 80, 80)
painter.drawText(QRectF(30, 20, 80, 80), Qt.AlignmentFlag.AlignCenter, "Invalid SVG")
# Draw selection border if this SVG is selected
if self.selected_svg == svg_path:
painter.setPen(QPen(QColor("#0000ff"), 3))
painter.setBrush(Qt.BrushStyle.NoBrush)
# Draw selection rectangle only around button area (140x120), not including label
painter.drawRect(2, 2, 136, 116)
painter.end()
button.paintEvent = paintEvent
# Connect click
button.clicked.connect(lambda: self._svg_button_click(svg_path))
# Add label below button with dedicated space
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) # No spacing between button and label
# Add button (takes most of the space)
layout.addWidget(button)
# Add label in dedicated space at bottom
filename = os.path.basename(svg_path)
display_name = filename[:-4] if filename.lower().endswith(".svg") else filename
label = QLabel(display_name)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setFixedHeight(20) # Dedicated space for label
label.setStyleSheet("QLabel { background-color: #f0f0f0; border-top: 1px solid #cccccc; color: #000000; }")
layout.addWidget(label)
return widget
[docs]
def _svg_button_click(self, svg_path: str) -> None:
"""
Handle SVG button click event.
"""
old_selection = self.selected_svg
self.selected_svg = svg_path
# Force repaint of both previously selected and newly selected buttons
if hasattr(self, "_svg_buttons"):
if old_selection in self._svg_buttons:
self._svg_buttons[old_selection].update()
if svg_path in self._svg_buttons:
self._svg_buttons[svg_path].update()
[docs]
def _create_modified_svg_renderer(self, svg_path: str, force_update: bool = False) -> QtSvg.QSvgRenderer:
"""
Create a modified SVG renderer with color and thickness applied.
Uses XML parsing instead of regex for more reliable modification.
Args:
svg_path: Path to the SVG file
force_update: If True, always create new renderer even if cached
"""
# Get color and thickness for this SVG
color = self._svg_colors.get(svg_path)
thickness = self._svg_thicknesses.get(svg_path)
# Always create modified SVG file if we have color or thickness
# Don't cache based on original file since modifications change the renderer
if color or thickness:
# Create modified SVG and get cache hash
cache_hash = self._modify_svg_file(svg_path, color, thickness)
# Store the cache hash for later use
self._modified_svg_files[svg_path] = cache_hash
# Get cache path and create renderer
cache_path = self._svg_cache.get_cache_path(cache_hash)
renderer = QtSvg.QSvgRenderer(str(cache_path))
self._svg_renderers[svg_path] = renderer
return renderer
else:
# No modifications, use original SVG
if force_update or svg_path not in self._svg_renderers:
self._svg_renderers[svg_path] = QtSvg.QSvgRenderer(svg_path)
return self._svg_renderers[svg_path]
[docs]
def _apply_color_to_svg(self) -> None:
"""
Apply the selected color to the selected SVG preview.
"""
if not self.selected_svg:
return # No SVG selected
if not self.shape_color:
return # No color selected
# Store the color for this SVG
self._svg_colors[self.selected_svg] = self.shape_color
# Create modified renderer with color (and thickness if any)
modified_renderer = self._create_modified_svg_renderer(self.selected_svg)
self._svg_renderers[self.selected_svg] = modified_renderer
# Force repaint of the selected SVG button
if self.selected_svg in self._svg_buttons:
self._svg_buttons[self.selected_svg].update()
[docs]
def _apply_thickness_to_svg(self) -> None:
"""
Apply the entered thickness to the selected SVG preview.
"""
if not self.selected_svg:
return # No SVG selected
if not self.thickness_input:
return # No thickness input widget
thickness_text = self.thickness_input.text().strip()
if not thickness_text:
return # No thickness entered
# Validate thickness is a positive number
try:
thickness = float(thickness_text)
if thickness <= 0:
return # Invalid thickness
except ValueError:
return # Not a valid number
# Store the thickness for this SVG
self._svg_thicknesses[self.selected_svg] = thickness_text
# Create modified renderer with thickness (and color if any)
modified_renderer = self._create_modified_svg_renderer(self.selected_svg)
self._svg_renderers[self.selected_svg] = modified_renderer
# Force repaint of the selected SVG button
if self.selected_svg in self._svg_buttons:
self._svg_buttons[self.selected_svg].update()
[docs]
def _apply_changes_to_svg(self) -> None:
"""
Apply both color and thickness changes to the selected SVG preview.
"""
if not self.selected_svg:
return # No SVG selected
applied_changes = False
# Apply color if selected
if self.shape_color:
self._svg_colors[self.selected_svg] = self.shape_color
applied_changes = True
# Apply thickness if entered
if self.thickness_input:
thickness_text = self.thickness_input.text().strip()
if thickness_text:
try:
thickness = float(thickness_text)
if thickness > 0:
self._svg_thicknesses[self.selected_svg] = thickness_text
applied_changes = True
except ValueError:
pass # Invalid thickness, ignore
if applied_changes:
# Create modified renderer with all changes
modified_renderer = self._create_modified_svg_renderer(self.selected_svg)
self._svg_renderers[self.selected_svg] = modified_renderer
# Force repaint of the selected SVG button
if self.selected_svg in self._svg_buttons:
self._svg_buttons[self.selected_svg].update()
[docs]
def _pick_color_from_dialog(self) -> None:
"""Open a QColorDialog to pick a custom color."""
color = QColorDialog.getColor()
if color.isValid():
hex_color = color.name()[1:]
self.shape_color = hex_color
if self.custom_color_input:
self.custom_color_input.setText(hex_color)
[docs]
def _main_dialog(self) -> QDialog:
"""
Method :
OpenSVGPickerInputDialog._main_dialog()
Parameters :
None
OpenSVGPickerInputDialog._main_dialog() --> QDialog
Creates and configures the main dialog window.
"""
dialog = QDialog()
dialog.setWindowTitle("Select SVG:")
dialog.resize(800, 800) # Increased size for larger previews
# Create SVG selection grid
svg_container = QWidget()
svg_layout = QGridLayout(svg_container)
svg_layout.setSpacing(15)
svg_layout.setContentsMargins(10, 10, 10, 10)
# Add SVG buttons in 3 columns
if not self.SVG_FILES:
label = QLabel("No SVG files found in data/SVG directory")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
svg_layout.addWidget(label, 0, 0, 1, 3)
else:
for i, svg_path in enumerate(self.SVG_FILES):
row = i // 3
col = i % 3
svg_widget = self._svg_widget(svg_path)
svg_layout.addWidget(svg_widget, row, col)
# Add stretch to fill remaining space
svg_layout.setRowStretch(svg_layout.rowCount(), 1)
# Create scroll area for SVG grid
scroll_area = QScrollArea()
scroll_area.setWidget(svg_container)
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# Create a text input field for custom color entry (optional)
from PySide6.QtWidgets import QLineEdit
self.custom_color_input = QLineEdit(dialog)
self.custom_color_input.setPlaceholderText("Enter custom color (e.g., #ff5733)")
# Create a text input field for SVG thickness
self.thickness_input = QLineEdit(dialog)
self.thickness_input.setPlaceholderText(f"Enter SVG thickness (default: {self.DEFAULT_SVG_THICKNESS})")
# self.thickness_input.setText(self.DEFAULT_SVG_THICKNESS) # Set default value
self.thickness_input.returnPressed.connect(self._apply_thickness_to_svg)
# Create OK/Cancel buttons
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, dialog)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
# Layout setup
main_layout = QHBoxLayout(dialog)
# Left side: SVG selection
left_layout = QVBoxLayout()
left_layout.addWidget(QLabel("Select SVG:"))
left_layout.addWidget(scroll_area)
left_layout.addWidget(QLabel("Custom color:"))
left_layout.addWidget(self.custom_color_input)
left_layout.addWidget(QLabel("SVG thickness:"))
left_layout.addWidget(self.thickness_input)
left_layout.addWidget(buttons)
# Right side: color selection
color_layout = QVBoxLayout()
color_layout.setContentsMargins(0, 0, 0, 0)
color_layout.setSpacing(2)
color_layout.addWidget(QLabel("Color history:"))
for code in self.color_codes:
btn = self._color_button(code)
btn.setFixedWidth(120)
color_layout.addWidget(btn)
pick_color_btn = QPushButton("Pick Color...")
pick_color_btn.clicked.connect(self._pick_color_from_dialog)
color_layout.addWidget(pick_color_btn)
# Add apply button to preview SVG with selected color and thickness
apply_button = QPushButton("Apply Changes")
apply_button.clicked.connect(self._apply_changes_to_svg)
color_layout.addWidget(apply_button)
color_layout.addStretch()
# Add layouts to main layout
main_layout.addLayout(left_layout, 3) # 3 parts for SVG
main_layout.addLayout(color_layout, 1) # 1 part for colors
QShortcut(QKeySequence("Ctrl+Return"), dialog, dialog.accept)
QShortcut(QKeySequence("Ctrl+Enter"), dialog, dialog.accept)
return dialog
[docs]
def _run_dialog(self) -> DialogResult:
"""
Method :
OpenSVGPickerInputDialog._run_dialog()
Parameters :
None
OpenSVGPickerInputDialog._run_dialog() --> Tuple[bool, str]
Runs the dialog and returns the result.
Returns (ok, filepath) where ok is True if accepted, filepath is the selected SVG file path.
"""
dialog = self._main_dialog()
# Run dialog and get result
if dialog.exec() == QDialog.DialogCode.Accepted:
# Determine final color (optional, kept for color history)
final_color = self.shape_color
if len(final_color) != 6:
# Try custom color input
if self.custom_color_input:
color_text = self.custom_color_input.text().strip()
if color_text:
if color_text.startswith("#"):
color_text = color_text[1:]
if len(color_text) == 6:
final_color = color_text
# If still no valid color, default to white
if len(final_color) != 6:
final_color = "ffffff"
# Check if SVG is selected
if not self.selected_svg:
# No SVG selected, return empty
ok = False
return ok, ""
# Append color to history if not already present
if final_color not in self.color_codes:
self.color_codes.append(final_color)
# Save color list
with open(self.color_dir + "/color_list.txt", "w") as f:
for code in self.color_codes:
f.write(str(code) + "\n")
# Get thickness if entered
thickness = None
if self.thickness_input:
thickness_text = self.thickness_input.text().strip()
if thickness_text:
try:
thickness_val = float(thickness_text)
if thickness_val > 0:
thickness = thickness_text
except ValueError:
pass # Invalid thickness, ignore
# Create final modified SVG file with all changes
cache_hash = self._modify_svg_file(self.selected_svg, final_color, thickness)
# Return cache hash
ok = True
return ok, cache_hash
else:
ok = False
return ok, ""