Source code for zooui.windows.dialogwindows.modifysvginputdialog
## 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 modification dialog for changing color and thickness of SVG objects."""
import os
import xml.etree.ElementTree as ET
from collections import deque
from typing import TYPE_CHECKING
from PySide6 import QtCore, QtSvg, QtWidgets
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,
QLineEdit,
QMessageBox,
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._xdg import get_cache_dir, get_colorstore_dir
if TYPE_CHECKING:
from PySide6.QtGui import QPaintEvent
from zooui.objects.mediaobjects.svgmediaobject import SVGMediaObject
# Type aliases
ColorCode = str
DialogResult = tuple[bool, str | None]
[docs]
class ModifySVGInputDialog:
"""
Constructor :
ModifySVGInputDialog(svg_media_object)
Parameters :
svg_media_object : SVGMediaObject
ModifySVGInputDialog(svg_media_object) --> None
Dialog for modifying color and thickness of existing SVG objects.
Designed for simple shapes (arrows, triangles, circles, squares)
that were added via svgpickerinputdialog.py.
"""
# Default values for non-cache files
DEFAULT_COLOR: str = "000000" # Black
DEFAULT_THICKNESS: str = "10"
[docs]
def __init__(self, svg_media_object: "SVGMediaObject") -> None:
"""
Constructor :
ModifySVGInputDialog(svg_media_object)
Parameters :
svg_media_object : SVGMediaObject
ModifySVGInputDialog(svg_media_object) --> None
Initialize dialog for modifying an existing SVG object.
"""
self.svg_object = svg_media_object
self.original_media_id = svg_media_object._media_id
self.__logger = get_logger("ModifySVGInputDialog")
# Determine source type
self.is_cache_file = self.original_media_id.startswith("svg_")
self.is_tmp_file = False
if not self.is_cache_file:
# Check if file path is in SVG cache directory
self.is_tmp_file = self.original_media_id.startswith(str(get_cache_dir() / "svg"))
# Current values (extract from SVG content)
self.current_color = self._extract_current_color()
self.current_thickness = self._extract_current_thickness()
# Modification state
self.modified_color: str | None = None
self.modified_thickness: str | None = None
self.preview_applied = False
# UI state
self.shape_color = self.current_color or self.DEFAULT_COLOR
self.color_codes: deque[ColorCode] = deque(maxlen=24)
self.custom_color_input: QLineEdit | None = None
self.thickness_input: QLineEdit | None = None
self.preview_widget: QWidget | None = None
# SVG cache
self._svg_cache = get_svg_cache()
# Load color history
self._load_color_history()
[docs]
def _load_color_history(self) -> None:
"""Load color history from color store file."""
color_dir = str(get_colorstore_dir())
color_file = os.path.join(color_dir, "color_list.txt")
if os.path.isfile(color_file):
with open(color_file) as f:
for line in f:
stripline = line.strip().lower()
if len(stripline) == 6 and stripline not in self.color_codes:
self.color_codes.append(stripline)
else:
# Create default colors if file doesn't exist
os.makedirs(color_dir, exist_ok=True)
with open(color_file, "w") as f:
for color in ["ffffff", "ff0000", "00ff00", "0000ff"]:
self.color_codes.append(color)
f.write(color + "\n")
[docs]
def _validate_svg_source(self) -> bool:
"""
Check if SVG is from safe source (SVG cache directory).
Show warning dialog for other sources.
Returns:
True if safe or user confirms, False if user cancels
"""
if self.is_cache_file or self.is_tmp_file:
return True # Safe cache file
# Show warning for non-cache files
return self._show_source_warning_dialog()
[docs]
def _show_source_warning_dialog(self) -> bool:
"""
Warning: This SVG was not added via SVG Picker dialog.
The modify dialog is designed for simple shapes from zooui/data/SVG/.
Default values will be used: color=black, stroke-width=10
Continue anyway?
"""
dialog = QMessageBox()
dialog.setWindowTitle("ZooUI - Warning")
dialog.setText("SVG Source Warning")
dialog.setInformativeText(
"This SVG was not added via the SVG Picker dialog.\n"
"The modify dialog is designed for simple shapes (arrows, triangles, circles, squares).\n\n"
"Default values will be used: color=black, stroke-width=10\n"
"Continue anyway?"
)
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
dialog.setDefaultButton(QtWidgets.QMessageBox.No)
return bool(dialog.exec() == QtWidgets.QMessageBox.Yes)
[docs]
def _color_name_to_hex(self, color_name: str) -> str:
"""
Convert color name to hex code.
Args:
color_name: Color name (e.g., 'black', 'red') or hex code
Returns:
Hex color without # (e.g., '000000')
"""
# If it's already hex, return it
if color_name.startswith("#"):
return color_name[1:].lower()
# Try to convert color name using QColor
try:
color = QColor(color_name)
if color.isValid():
# Convert to hex without #
return str(color.name()[1:].lower())
except Exception:
pass
# Default to black if conversion fails
return "000000"
[docs]
def _extract_current_color(self) -> str | None:
"""
Extract color from SVG content.
For simple shapes, look for stroke or fill attributes.
Returns hex color without # (e.g., '000000' for black).
"""
content = self.svg_object.get_svg_content()
if not content:
return None
try:
# Parse XML
root = ET.fromstring(content)
# Define namespace
ns = {"svg": "http://www.w3.org/2000/svg"}
# Look for stroke or fill attributes in shape elements
for tag in ["path", "rect", "circle", "ellipse", "line", "polyline", "polygon"]:
elements = root.findall(f".//svg:{tag}", ns) or []
for elem in elements:
# Check stroke first
stroke = elem.get("stroke")
if stroke and stroke.lower() not in ("none", "transparent"):
return self._color_name_to_hex(stroke)
# Check fill
fill = elem.get("fill")
if fill and fill.lower() not in ("none", "transparent"):
return self._color_name_to_hex(fill)
# Also check root element
stroke = root.get("stroke")
if stroke and stroke.lower() not in ("none", "transparent"):
return self._color_name_to_hex(stroke)
except Exception as e:
self.__logger.error("Error extracting color from SVG: %s", e)
return None
[docs]
def _extract_current_thickness(self) -> str | None:
"""
Extract stroke-width from SVG content.
Returns string value (e.g., '8').
"""
content = self.svg_object.get_svg_content()
if not content:
return None
try:
# Parse XML
root = ET.fromstring(content)
# Define namespace
ns = {"svg": "http://www.w3.org/2000/svg"}
# Look for stroke-width in shape elements
for tag in ["path", "rect", "circle", "ellipse", "line", "polyline", "polygon"]:
elements = root.findall(f".//svg:{tag}", ns) or []
for elem in elements:
stroke_width = elem.get("stroke-width")
if stroke_width:
return stroke_width
except Exception as e:
self.__logger.error("Error extracting thickness from SVG: %s", e)
return None
[docs]
def _color_square(self, color_code: ColorCode) -> QWidget:
"""
Create 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:
"""
Handle color button click event.
"""
self.shape_color = color
self.modified_color = color
self._update_preview()
[docs]
def _color_button(self, color_code: ColorCode) -> QWidget:
"""
Create 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_preview_widget(self) -> QWidget:
"""
Create widget showing the SVG preview (300x300).
"""
widget = QWidget()
widget.setFixedSize(300, 300)
# Create a custom paint event
#
# NOTE: QPainter MUST be explicitly ended via .end() at every
# exit point. An unended painter left on its paint device
# corrupts Qt's C++ paint engine, causing SIGSEGV crashes
# after extended runtime (e.g. hours/days of use).
def paintEvent(event: "QPaintEvent") -> None:
painter = QPainter(widget)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Clear background with light gray
painter.fillRect(widget.rect(), QColor("#c0c0c0"))
# Draw border
painter.setPen(QPen(QColor("#cccccc"), 2))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(2, 2, 296, 296)
# Get SVG content to render
svg_content = self._get_current_svg_content()
if not svg_content:
# Draw placeholder
painter.setPen(QPen(QColor("#ff0000"), 2))
painter.setBrush(QBrush(QColor("#ffcccc")))
painter.drawRect(50, 50, 200, 200)
painter.drawText(QRectF(50, 50, 200, 200), Qt.AlignmentFlag.AlignCenter, "No SVG content")
painter.end()
return
# Create temporary renderer
renderer = QtSvg.QSvgRenderer()
if not renderer.load(QtCore.QByteArray(svg_content.encode("utf-8"))):
# Draw error placeholder
painter.setPen(QPen(QColor("#ff0000"), 2))
painter.setBrush(QBrush(QColor("#ffcccc")))
painter.drawRect(50, 50, 200, 200)
painter.drawText(QRectF(50, 50, 200, 200), Qt.AlignmentFlag.AlignCenter, "Invalid SVG")
painter.end()
return
# Calculate scaling to fit within 280x280 area (with 10px margin)
svg_size = renderer.defaultSize()
if svg_size.width() > 0 and svg_size.height() > 0:
target_size = 280
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 = (300 - scaled_width) / 2
y_offset = (300 - scaled_height) / 2
# Render the SVG
renderer.render(painter, QRectF(x_offset, y_offset, scaled_width, scaled_height))
# Required at every exit path (see note at top of paintEvent)
painter.end()
widget.paintEvent = paintEvent
self.preview_widget = widget
return widget
[docs]
def _get_current_svg_content(self) -> str | None:
"""
Get current SVG content with modifications applied.
"""
# Get original content
content = self.svg_object.get_svg_content()
if not content:
return None
# Apply modifications if any
color = self.modified_color or self.current_color
thickness = self.modified_thickness if self.preview_applied else self.current_thickness
if not color and not thickness:
return content
try:
# Parse XML
root = ET.fromstring(content)
# Define namespace
ns = {"svg": "http://www.w3.org/2000/svg"}
# Find all shape elements
elements = []
for tag in ["path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "g"]:
elements.extend(root.findall(f".//svg:{tag}", ns) or [])
# Also include root element for color
elements.append(root)
for elem in elements:
# Apply color if specified
if color:
color_hex = f"#{color}"
# Update stroke attribute
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":
pass # Keep as "none"
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")
):
elem.set("stroke", color_hex)
# Update fill attribute
fill = elem.get("fill")
if fill is not None and fill.lower() not in ("none", "transparent"):
elem.set("fill", color_hex)
# Apply thickness if specified and preview is applied
if thickness and self.preview_applied and elem != root:
elem.set("stroke-width", thickness)
# Convert back to string
return str(ET.tostring(root, encoding="utf-8").decode("utf-8"))
except Exception as e:
self.__logger.error("Error modifying SVG content: %s", e)
return content
[docs]
def _update_preview(self) -> None:
"""Update the preview widget."""
if self.preview_widget:
self.preview_widget.update()
[docs]
def _apply_preview(self) -> None:
"""Apply thickness changes to preview."""
if self.thickness_input:
thickness_text = self.thickness_input.text().strip()
if thickness_text:
try:
thickness = float(thickness_text)
if thickness > 0:
self.modified_thickness = thickness_text
self.preview_applied = True
self._update_preview()
except ValueError:
pass # Invalid thickness
[docs]
def _reset_to_original(self) -> None:
"""Reset to original values."""
self.modified_color = None
self.modified_thickness = None
self.preview_applied = False
self.shape_color = self.current_color or self.DEFAULT_COLOR
if self.thickness_input:
self.thickness_input.setText(self.current_thickness or self.DEFAULT_THICKNESS)
self._update_preview()
[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
self.modified_color = hex_color
if self.custom_color_input:
self.custom_color_input.setText(hex_color)
self._update_preview()
[docs]
def _modify_svg_file(self, color: str | None, thickness: str | None) -> str:
"""
Create modified SVG in cache.
Returns:
Cache hash of modified SVG
"""
# Get SVG content
content = self.svg_object.get_svg_content()
if not content:
# Fallback: create simple SVG
content = '<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"><circle cx="100" cy="100" r="70" stroke="black" stroke-width="8" fill="none"/></svg>'
try:
# Parse XML
root = ET.fromstring(content)
# Define namespace
ns = {"svg": "http://www.w3.org/2000/svg"}
# Find all shape elements
elements = []
for tag in ["path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "g"]:
elements.extend(root.findall(f".//svg:{tag}", ns) or [])
# Also include root element for color
elements.append(root)
for elem in elements:
# Apply color if specified
if color:
color_hex = f"#{color}"
# Update stroke attribute
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":
pass # Keep as "none"
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")
):
elem.set("stroke", color_hex)
# Update fill attribute
fill = elem.get("fill")
if fill is not None and fill.lower() not in ("none", "transparent"):
elem.set("fill", color_hex)
# Apply thickness if specified
if thickness and elem != root:
elem.set("stroke-width", thickness)
# Convert back 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:
self.__logger.error("Error creating modified SVG: %s", e)
# Fallback: store original content in cache
return self._svg_cache.store_svg(content)
[docs]
def _main_dialog(self) -> QDialog:
"""
Create and configure the main dialog window.
"""
dialog = QDialog()
dialog.setWindowTitle("Modify SVG")
dialog.setMinimumWidth(800)
dialog.adjustSize()
# Create main layout
main_layout = QHBoxLayout(dialog)
# Left side: SVG preview
left_layout = QVBoxLayout()
left_layout.addWidget(QLabel("SVG Preview:"))
preview_widget = self._svg_preview_widget()
left_layout.addWidget(preview_widget)
left_layout.addStretch()
# Right side: controls
right_layout = QVBoxLayout()
right_layout.setContentsMargins(10, 0, 0, 0)
# Color history
right_layout.addWidget(QLabel("Color History:"))
color_scroll = QScrollArea()
color_scroll.setWidgetResizable(True)
color_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
color_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
color_scroll.setFrameShape(QFrame.Shape.NoFrame)
color_container = QWidget()
color_grid = QGridLayout(color_container)
color_grid.setSpacing(5)
# Add color buttons in 2 columns
for i, code in enumerate(self.color_codes):
row = i // 2
col = i % 2
btn = self._color_button(code)
btn.setFixedWidth(140)
color_grid.addWidget(btn, row, col)
color_scroll.setWidget(color_container)
right_layout.addWidget(color_scroll)
pick_color_btn = QPushButton("Pick Color...")
pick_color_btn.clicked.connect(self._pick_color_from_dialog)
right_layout.addWidget(pick_color_btn)
# Color input
right_layout.addWidget(QLabel("Color (hex without #):"))
self.custom_color_input = QLineEdit()
self.custom_color_input.setText(self.shape_color)
self.custom_color_input.textChanged.connect(lambda text: self._color_button_click(text.strip()))
right_layout.addWidget(self.custom_color_input)
# Thickness input
right_layout.addWidget(QLabel("Stroke Thickness:"))
self.thickness_input = QLineEdit()
self.thickness_input.setText(self.current_thickness or self.DEFAULT_THICKNESS)
right_layout.addWidget(self.thickness_input)
# Apply/Reset buttons
button_layout = QHBoxLayout()
apply_btn = QPushButton("Apply Preview")
apply_btn.clicked.connect(self._apply_preview)
button_layout.addWidget(apply_btn)
reset_btn = QPushButton("Reset")
reset_btn.clicked.connect(self._reset_to_original)
button_layout.addWidget(reset_btn)
right_layout.addLayout(button_layout)
# OK/Cancel buttons
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, dialog)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
right_layout.addWidget(buttons)
right_layout.addStretch()
# Add layouts to main layout
main_layout.addLayout(left_layout, 2) # 2 parts for preview
main_layout.addLayout(right_layout, 1) # 1 part for controls
QShortcut(QKeySequence("Ctrl+Return"), dialog, dialog.accept)
QShortcut(QKeySequence("Ctrl+Enter"), dialog, dialog.accept)
return dialog
[docs]
def _run_dialog(self) -> DialogResult:
"""
Run the dialog and return result.
Returns:
(ok, cache_hash) where:
- ok: True if accepted, False if cancelled
- cache_hash: New cache hash if modified, None if no changes
"""
# Validate source (show warning if needed)
if not self._validate_svg_source():
return False, None
# For non-cache files, use defaults
if not (self.is_cache_file or self.is_tmp_file):
self.current_color = self.DEFAULT_COLOR
self.current_thickness = self.DEFAULT_THICKNESS
self.shape_color = self.DEFAULT_COLOR
self.modified_color = self.DEFAULT_COLOR
self.modified_thickness = self.DEFAULT_THICKNESS
# Create and show dialog
dialog = self._main_dialog()
if dialog.exec() == QDialog.DialogCode.Accepted:
# Determine final color
final_color = self.modified_color or self.current_color or self.DEFAULT_COLOR
# Determine final thickness
final_thickness = None
if self.thickness_input:
thickness_text = self.thickness_input.text().strip()
if thickness_text:
try:
thickness = float(thickness_text)
if thickness > 0:
final_thickness = thickness_text
except ValueError:
pass
# Check if any changes were made
color_changed = final_color != (self.current_color or self.DEFAULT_COLOR)
thickness_changed = final_thickness != (self.current_thickness or self.DEFAULT_THICKNESS)
if color_changed or thickness_changed:
# Create modified SVG in cache
cache_hash = self._modify_svg_file(final_color, final_thickness)
# Update color history
if final_color not in self.color_codes:
self.color_codes.append(final_color)
# Save color list
color_dir = str(get_colorstore_dir())
color_file = os.path.join(color_dir, "color_list.txt")
with open(color_file, "w") as f:
for code in self.color_codes:
f.write(str(code) + "\n")
return True, cache_hash
else:
# No changes made
return True, None
else:
# User cancelled
return False, None