Utility_Apps/Svg2PngPdf/Archived/svg_to_png_gui_G-v2-NewTheme.py

1163 lines
No EOL
61 KiB
Python

#!/usr/bin/env python3
import os
import sys
import subprocess
import platform
import json
import threading
from pathlib import Path
from tkinter import (Tk, Frame, Label, Button, Listbox, Scrollbar, Entry, Spinbox,
Checkbutton, LabelFrame, scrolledtext, Toplevel, colorchooser,
IntVar, StringVar, BooleanVar, DoubleVar, TclError,
BOTH, X, Y, LEFT, RIGHT, VERTICAL, HORIZONTAL, W, E, EW, END, WORD, SUNKEN, MULTIPLE,
NS, NSEW) # Import missing constants
from tkinter import ttk, filedialog, messagebox
# tkinterdnd2 needs to be installed: pip install tkinterdnd2
try:
from tkinterdnd2 import TkinterDnD, DND_FILES
except ImportError:
messagebox.showerror("Error", "tkinterdnd2 library not found.\nPlease install it using: pip install tkinterdnd2")
sys.exit(1)
# Pillow needs to be installed: pip install Pillow (Optional, but good practice)
try:
from PIL import Image, ImageTk
except ImportError:
print("Warning: Pillow (PIL) not found. Image operations might be limited.")
# darkdetect needs to be installed: pip install darkdetect
try:
import darkdetect
except ImportError:
print("Warning: darkdetect library not found. Automatic dark mode detection disabled.")
darkdetect = None # Set to None to handle gracefully
class SVGtoPNGConverter(TkinterDnD.Tk):
# Updated theme colors based on BlenderBIM specification
BLENDER_THEME = {
"bg": "#323232", # --blender-bg (Primary Background) - Use for main window
"panel_bg": "#252526", # --blender-panel (Panel Background) - Use for frames, widgets
"highlight": "#4f84d1", # --blender-highlight (Highlight Color) - Active, selected, focus
"highlight_hover": "#679ae0", # --blender-highlight-hover (Not directly mappable in ttk 'active', use highlight)
"text": "#e0e0e0", # --blender-text (Text)
"text_input": "#eeeeee", # --blender-input-text (Input Text)
"border": "#1e1e1e", # --blender-border (Borders) - Crucial for definition
"button": "#535353", # --blender-button (Buttons Default)
"button_hover": "#4f84d1", # --blender-button Hover (use highlight)
"button_active": "#4f84d1",# --blender-button Active (use highlight for pressed)
"danger_button": "#a04040", # --blender-danger-button (Not used yet, but defined)
"danger_button_hover": "#c05050", # --blender-danger-button-hover (Not used yet)
"input_bg": "#444444", # --blender-input-bg (Input Background)
"disabled_text": "#888888", # --blender-disabled-text (Disabled Text)
"section_bg": "#3a3a3a", # --blender-section-bg (Section Headers) - For LabelFrame?
"subsection_bg": "#424242", # --blender-subsection-bg (Property Groups) - Could use for inner frames if needed
"scrollbar_thumb": "#535353", # Scrollbar thumb (use button color)
"scrollbar_trough": "#444444", # Scrollbar track (use input bg)
"scrollbar_hover": "#4f84d1" # Scrollbar hover (use highlight)
}
# Updated fonts based on BlenderBIM specification
BLENDER_FONTS = {
"primary": ("Segoe UI", 13), # Base Font Size: 13px
"monospace": ("Consolas", 12), # Monospace Font (adjust size if needed)
"header": ("Segoe UI", 16, "bold"), # Header h2: ~1.2em -> ~16px Bold
"section": ("Segoe UI", 13, "bold"), # Section h4: 1em -> 13px Bold (weight 600 ~ bold)
"label": ("Segoe UI", 12), # Control labels: 0.9em -> ~12px
"small": ("Segoe UI", 11), # Small text: 0.85em -> ~11px
}
def __init__(self):
super().__init__()
self.title("SVG Converter Pro (BlenderBIM Style)") # New title
self.geometry("900x700")
self.minsize(800, 600)
# Initialize variables
self.selected_files = []
self.failed_files = []
self.conversion_running = False
self.dark_mode = self._detect_dark_mode()
# Load settings
self.settings_file = Path.home() / ".svg_converter_pro_settings.json" # New settings file name
self.load_settings()
# Setup UI *before* applying theme
self.style = ttk.Style(self)
self.setup_ui() # UI elements are created here
# Check Inkscape availability
self.inkscape_path = self.find_inkscape()
if not self.inkscape_path:
self.log("Warning: Inkscape not found. Conversion disabled until path is valid.", "warning")
else:
self.log(f"Found Inkscape: {self.inkscape_path}", "info")
# Configure window close behavior
self.protocol("WM_DELETE_WINDOW", self.on_close)
# Apply the initial theme (light or dark)
self.update_theme()
def _detect_dark_mode(self):
if darkdetect and hasattr(darkdetect, 'isDark'):
try: return darkdetect.isDark()
except Exception as e: print(f"Error detecting dark mode: {e}"); return False
return False
def create_blender_theme(self):
"""Creates the 'blenderbim' ttk theme based on the detailed spec."""
if 'blenderbim' in self.style.theme_names():
return True # Already exists
try:
# Use 'clam' as parent, it's often cleaner for custom styling
self.style.theme_create("blenderbim", parent="clam")
# Define settings based on spec
self.style.theme_settings("blenderbim", {
".": { # Root style
"configure": {
"background": self.BLENDER_THEME["panel_bg"], # Default widget bg
"foreground": self.BLENDER_THEME["text"], # Default text
"bordercolor": self.BLENDER_THEME["border"], # Default border
"focuscolor": self.BLENDER_THEME["highlight"], # Color for focus ring (theme decides how to draw)
"font": self.BLENDER_FONTS["primary"],
"lightcolor": self.BLENDER_THEME["panel_bg"], # Used in some reliefs
"darkcolor": self.BLENDER_THEME["border"], # Used in some reliefs
"relief": "flat", # Generally flat look
"borderwidth": 0, # Default to no border unless specified
}
},
"TFrame": { # Style for ttk Frames
"configure": {
"background": self.BLENDER_THEME["panel_bg"],
"relief": "flat",
"borderwidth": 0,
}
},
"TLabel": { # Style for ttk Labels
"configure": {
"background": self.BLENDER_THEME["panel_bg"],
"foreground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["label"], # Use label font size
"padding": (0, 2), # Add a little vertical padding
"anchor": "w", # Default to left-align
}
},
"TLabelFrame": { # Style for LabelFrames (used for sections)
"configure": {
"background": self.BLENDER_THEME["panel_bg"], # Background *around* the frame
"foreground": self.BLENDER_THEME["text"], # Text color of the label
"font": self.BLENDER_FONTS["section"], # Use section font
"relief": "solid", # Spec implies border
"borderwidth": 1,
"bordercolor": self.BLENDER_THEME["border"],
"padding": 10, # Padding inside the frame
}
},
"TLabelFrame.Label": { # Style specifically for the Label within the LabelFrame
"configure": {
"background": self.BLENDER_THEME["panel_bg"], # Match parent frame bg
"foreground": self.BLENDER_THEME["text"],
"font": self.BLENDER_FONTS["section"],
"padding": (5, 0, 5, 2), # Adjust padding (left, top, right, bottom)
}
},
"TButton": { # Style for ttk Buttons
"configure": {
"background": self.BLENDER_THEME["button"],
"foreground": self.BLENDER_THEME["text"],
"bordercolor": self.BLENDER_THEME["border"],
"borderwidth": 1,
"font": self.BLENDER_FONTS["primary"],
"padding": (8, 4), # Horizontal, Vertical padding
"anchor": "center",
"relief": "raised", # Gives a slight 3D look, common in Blender
},
"map": {
"background": [
("pressed", "!disabled", self.BLENDER_THEME["button_active"]), # Pressed state uses active color
("active", "!disabled", self.BLENDER_THEME["button_hover"]), # Hover state ('active' in ttk)
("disabled", self.BLENDER_THEME["panel_bg"]) # Disabled background
],
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])],
"relief": [("pressed", "!disabled", "sunken")], # Sunken relief when pressed
"bordercolor": [
("focus", self.BLENDER_THEME["highlight"]), # Border becomes highlight on focus
("!focus", self.BLENDER_THEME["border"])
]
}
},
"TEntry": { # Style for ttk Entry widgets
"configure": {
"fieldbackground": self.BLENDER_THEME["input_bg"],
"foreground": self.BLENDER_THEME["text_input"],
"bordercolor": self.BLENDER_THEME["border"],
"borderwidth": 1,
"font": self.BLENDER_FONTS["primary"],
"insertcolor": self.BLENDER_THEME["text_input"], # Cursor color
"insertwidth": 1,
"padding": (5, 4), # Horizontal, Vertical padding
"relief": "solid", # Flat border
},
"map": {
"bordercolor": [("focus", self.BLENDER_THEME["highlight"])], # Highlight border on focus
"fieldbackground": [("disabled", self.BLENDER_THEME["panel_bg"])],
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])],
}
},
"TSpinbox": { # Style for ttk Spinbox widgets
"configure": {
# Inherits fieldbackground, foreground from TEntry via 'clam' parent
"bordercolor": self.BLENDER_THEME["border"],
"borderwidth": 1,
"arrowcolor": self.BLENDER_THEME["text"],
"background": self.BLENDER_THEME["button"], # Background of the arrow buttons
"relief": "solid",
"arrowsize": 10, # Adjust size if needed
"padding": (5, 4),
},
"map": {
"background": [ # Button background hover
("active", "!disabled", self.BLENDER_THEME["button_hover"]),
("disabled", self.BLENDER_THEME["panel_bg"])
],
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])], # Text color
"bordercolor": [("focus", self.BLENDER_THEME["highlight"])], # Highlight border on focus
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])],
}
},
"TCombobox": { # Style for ttk Combobox widgets
"configure": {
"fieldbackground": self.BLENDER_THEME["input_bg"],
"foreground": self.BLENDER_THEME["text_input"],
"bordercolor": self.BLENDER_THEME["border"],
"borderwidth": 1,
"arrowcolor": self.BLENDER_THEME["text"],
"background": self.BLENDER_THEME["button"], # Arrow button background
"insertcolor": self.BLENDER_THEME["text_input"],
"padding": (5, 4),
"relief": "solid",
"arrowsize": 12,
},
"map": {
"bordercolor": [("focus", self.BLENDER_THEME["highlight"])],
# Background of the dropdown arrow button
"background": [
("active", "!disabled", self.BLENDER_THEME["button_hover"]), # Hover state
("readonly", self.BLENDER_THEME["button"]), # Keep button color when readonly
("disabled", self.BLENDER_THEME["panel_bg"])
],
"fieldbackground": [("disabled", self.BLENDER_THEME["panel_bg"]),
("readonly", self.BLENDER_THEME["input_bg"])],
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"]),
("readonly", self.BLENDER_THEME["text_input"])],
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])],
}
},
# Style the dropdown list part of the Combobox
# Note: This might not work reliably on all platforms/ttk versions
"TCombobox.Listbox": {
"configure": {
"background": self.BLENDER_THEME["input_bg"],
"foreground": self.BLENDER_THEME["text_input"],
"selectbackground": self.BLENDER_THEME["highlight"],
"selectforeground": self.BLENDER_THEME["text_input"], # Keep input text color on selection
"font": self.BLENDER_FONTS["primary"],
"borderwidth": 1,
"bordercolor": self.BLENDER_THEME["border"],
"relief":"solid",
}
},
"TCheckbutton": { # Style for ttk Checkbuttons
"configure": {
"background": self.BLENDER_THEME["panel_bg"], # Background of the label area
"foreground": self.BLENDER_THEME["text"], # Label text color
"font": self.BLENDER_FONTS["label"],
"indicatorbackground": self.BLENDER_THEME["input_bg"], # Box bg when off
"indicatorforeground": self.BLENDER_THEME["text"], # Checkmark color
"indicatormargin": 2,
"indicatorrelief": "solid",
"indicatorborderwidth": 1,
"indicatorbordercolor": self.BLENDER_THEME["border"],
"padding": (5, 3),
},
"map": {
"foreground": [("disabled", self.BLENDER_THEME["disabled_text"])],
"indicatorbackground": [
("selected", self.BLENDER_THEME["highlight"]), # Box bg when checked
("pressed", self.BLENDER_THEME["highlight"]), # Box bg while pressing
("active", self.BLENDER_THEME["input_bg"]) # Box bg on hover (keep it same as default off)
],
"indicatorbordercolor": [("disabled", self.BLENDER_THEME["disabled_text"])]
# Checkmark color could also be mapped if needed
# "indicatorforeground": [("selected", self.BLENDER_THEME["text"])]
}
},
"Vertical.TScrollbar": { # Style for Vertical Scrollbars
"configure": {
"background": self.BLENDER_THEME["scrollbar_thumb"], # Thumb color
"troughcolor": self.BLENDER_THEME["scrollbar_trough"], # Track color
"bordercolor": self.BLENDER_THEME["border"], # Border around track/thumb
"arrowcolor": self.BLENDER_THEME["text"], # Arrow color
"relief": "flat",
"borderwidth": 0,
"width": 12, # Slightly wider scrollbar
"arrowsize": 14,
},
"map": {
"background": [ # Thumb hover color
("active", self.BLENDER_THEME["scrollbar_hover"])
],
"troughcolor": [], # Prevent trough changing color on hover etc.
"bordercolor": [],
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])]
}
},
"Horizontal.TScrollbar": { # Style for Horizontal Scrollbars
"configure": {
"background": self.BLENDER_THEME["scrollbar_thumb"],
"troughcolor": self.BLENDER_THEME["scrollbar_trough"],
"bordercolor": self.BLENDER_THEME["border"],
"arrowcolor": self.BLENDER_THEME["text"],
"relief": "flat",
"borderwidth": 0,
"height": 12, # Match width
"arrowsize": 14,
},
"map": {
"background": [("active", self.BLENDER_THEME["scrollbar_hover"])],
"troughcolor": [],
"bordercolor": [],
"arrowcolor": [("disabled", self.BLENDER_THEME["disabled_text"])]
}
},
"TProgressbar": { # Style for Progress bars
"configure": {
"background": self.BLENDER_THEME["highlight"], # The moving bar color
"troughcolor": self.BLENDER_THEME["input_bg"], # The background track
"bordercolor": self.BLENDER_THEME["border"],
"borderwidth": 1,
"relief": "solid",
"thickness": 20, # Height of the bar
}
}
}) # End of theme_settings
print("BlenderBIM theme created successfully.")
return True
except Exception as e:
self.log(f"Error creating BlenderBIM theme: {e}", "error")
# Optionally remove partially created theme if it fails
if 'blenderbim' in self.style.theme_names():
try: self.style.theme_delete("blenderbim")
except: pass
return False
def setup_ui(self):
"""Setup the main user interface elements"""
# Main container - Use standard Frame, theme handles background
main_frame = Frame(self, name="main_frame") # Give it a name for easier targeting if needed
main_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
# --- Top panel: File selection ---
file_frame = ttk.LabelFrame(main_frame, text="Files to Convert", padding=10)
file_frame.pack(fill=X, pady=(0, 10))
file_frame.grid_columnconfigure(0, weight=1) # Make listbox expand horizontally
# File list - Use standard Listbox, style manually in update_theme
self.file_list = Listbox(file_frame, selectmode=MULTIPLE, height=8,
font=self.BLENDER_FONTS["primary"], # Use primary font
exportselection=False, # Prevent selection loss on focus change
# Border/bg/fg set in update_theme
)
self.file_list.grid(row=0, column=0, sticky="nsew", pady=(0, 2))
list_scroll_y = ttk.Scrollbar(file_frame, orient=VERTICAL, command=self.file_list.yview)
list_scroll_y.grid(row=0, column=1, sticky="ns", pady=(0, 2))
self.file_list.config(yscrollcommand=list_scroll_y.set)
list_scroll_x = ttk.Scrollbar(file_frame, orient=HORIZONTAL, command=self.file_list.xview)
list_scroll_x.grid(row=1, column=0, sticky="ew")
self.file_list.config(xscrollcommand=list_scroll_x.set)
# File control buttons
file_controls = ttk.Frame(file_frame, style="TFrame") # Ensure frame uses theme bg
file_controls.grid(row=0, column=2, rowspan=2, sticky="ns", padx=(10, 0))
ttk.Button(file_controls, text="Add Files", command=self.add_files, width=10).pack(fill=X, pady=2)
ttk.Button(file_controls, text="Add Folder", command=self.add_folder, width=10).pack(fill=X, pady=2)
ttk.Button(file_controls, text="Remove", command=self.remove_files, width=10).pack(fill=X, pady=(10, 2)) # Add space above remove
ttk.Button(file_controls, text="Clear All", command=self.clear_files, width=10).pack(fill=X, pady=2)
# Drag and drop setup
self.file_list.drop_target_register(DND_FILES)
self.file_list.dnd_bind('<<Drop>>', self.handle_drop)
file_frame.drop_target_register(DND_FILES)
file_frame.dnd_bind('<<Drop>>', self.handle_drop)
self.drop_target_register(DND_FILES)
self.dnd_bind('<<Drop>>', self.handle_drop)
# --- Middle panel: Settings ---
settings_frame = ttk.LabelFrame(main_frame, text="Conversion Settings", padding=10)
settings_frame.pack(fill=X, pady=(0, 10))
settings_frame.columnconfigure(1, weight=1) # Allow DPI/BG inputs some space
settings_frame.columnconfigure(3, weight=1) # Allow Output entry to expand more
# Row 0: DPI, Background, Format
ttk.Label(settings_frame, text="DPI:", font=self.BLENDER_FONTS["label"]).grid(row=0, column=0, sticky=W, padx=(0, 5), pady=2)
self.dpi_var = IntVar(value=self.settings.get('dpi', 96))
# Use TSpinbox for consistency
ttk.Spinbox(settings_frame, from_=72, to=1200, increment=24, textvariable=self.dpi_var, width=7, font=self.BLENDER_FONTS["primary"]).grid(row=0, column=1, sticky=W, pady=2)
ttk.Label(settings_frame, text="Background:", font=self.BLENDER_FONTS["label"]).grid(row=0, column=2, sticky=W, padx=(15, 5), pady=2)
self.bg_var = StringVar(value=self.settings.get('background', '#FFFFFF'))
bg_frame = ttk.Frame(settings_frame) # Use ttk.Frame for consistent bg
bg_frame.grid(row=0, column=3, sticky=W, pady=2)
self.bg_entry = ttk.Entry(bg_frame, textvariable=self.bg_var, width=10, font=self.BLENDER_FONTS["primary"])
self.bg_entry.pack(side=LEFT)
# Color swatch - Use standard Label, style manually
self.bg_swatch = Label(bg_frame, text=" ", relief="solid", borderwidth=1)
self.bg_swatch.pack(side=LEFT, padx=(4, 5))
self.bg_var.trace_add("write", lambda *args: self.update_color_swatch())
ttk.Button(bg_frame, text="Pick", command=self.pick_color, width=5).pack(side=LEFT) # Smaller padding
ttk.Label(settings_frame, text="Format:", font=self.BLENDER_FONTS["label"]).grid(row=0, column=4, sticky=W, padx=(15, 5), pady=2)
self.format_var = StringVar(value=self.settings.get('format', 'png'))
formats = ['png', 'jpg', 'pdf', 'tiff', 'eps', 'svg', 'ps', 'webp'] # Added webp
ttk.Combobox(settings_frame, textvariable=self.format_var, values=formats, width=7, state='readonly', font=self.BLENDER_FONTS["primary"]).grid(row=0, column=5, sticky=W, pady=2)
# Row 1: Output Directory
ttk.Label(settings_frame, text="Output Dir:", font=self.BLENDER_FONTS["label"]).grid(row=1, column=0, sticky=W, padx=(0, 5), pady=(8, 2))
self.output_var = StringVar(value=self.settings.get('output_dir', 'Same as source'))
output_frame = ttk.Frame(settings_frame)
output_frame.grid(row=1, column=1, columnspan=5, sticky=EW, pady=(8, 2))
output_frame.columnconfigure(0, weight=1) # Make entry expand
self.output_entry = ttk.Entry(output_frame, textvariable=self.output_var, font=self.BLENDER_FONTS["primary"])
self.output_entry.grid(row=0, column=0, sticky=EW)
ttk.Button(output_frame, text="Browse", command=self.choose_output_dir, width=8).grid(row=0, column=1, sticky=W, padx=(5, 0))
# Row 2: Options (Checkboxes)
options_frame = ttk.Frame(settings_frame)
options_frame.grid(row=2, column=0, columnspan=6, sticky=W, pady=(10, 0))
self.overwrite_var = BooleanVar(value=self.settings.get('overwrite', False))
ttk.Checkbutton(options_frame, text="Overwrite existing files", variable=self.overwrite_var).pack(side=LEFT, padx=(0, 15))
self.auto_open_var = BooleanVar(value=self.settings.get('auto_open', False))
ttk.Checkbutton(options_frame, text="Open output folder on completion", variable=self.auto_open_var).pack(side=LEFT)
# --- Bottom panel: Log and Controls ---
log_frame = ttk.LabelFrame(main_frame, text="Conversion Log", padding=10)
log_frame.pack(fill=BOTH, expand=True, pady=(0, 10))
log_frame.rowconfigure(0, weight=1) # Make text area expand vertically
log_frame.columnconfigure(0, weight=1) # Make text area expand horizontally
# Log area - Use standard ScrolledText, style manually
self.log_area = scrolledtext.ScrolledText(log_frame, wrap=WORD, state='disabled', height=10,
font=self.BLENDER_FONTS["monospace"], # Use monospace
# Border/bg/fg set in update_theme
)
self.log_area.grid(row=0, column=0, sticky="nsew")
# Configure log tags (initial colors, updated in update_theme)
self.log_area.tag_config("info", foreground="blue")
self.log_area.tag_config("success", foreground="green")
self.log_area.tag_config("warning", foreground="orange")
self.log_area.tag_config("error", foreground="red")
# Progress bar
self.progress_var = DoubleVar()
self.progress_bar = ttk.Progressbar(log_frame, variable=self.progress_var, maximum=100)
self.progress_bar.grid(row=1, column=0, sticky=EW, pady=(8, 0)) # Increased padding top
# Status bar - Use a Label inside the log_frame
self.status_var = StringVar(value="Ready")
# Use label font for status
status_bar = ttk.Label(log_frame, textvariable=self.status_var, anchor=W, font=self.BLENDER_FONTS["label"])
status_bar.grid(row=2, column=0, sticky=EW, pady=(5, 0))
# --- Control Buttons ---
control_frame = ttk.Frame(main_frame) # Use ttk.Frame
control_frame.pack(fill=X, pady=(5, 0))
self.convert_button = ttk.Button(control_frame, text="Convert", command=self.start_conversion, width=12)
self.convert_button.pack(side=LEFT, padx=(0, 10))
self.retry_button = ttk.Button(control_frame, text="Retry Failed", command=self.retry_failed, width=12, state='disabled')
self.retry_button.pack(side=LEFT, padx=(0, 10))
self.clear_log_button = ttk.Button(control_frame, text="Clear Log", command=self.clear_log, width=10)
self.clear_log_button.pack(side=LEFT)
# Theme toggle button - Place it at the far right
self.theme_button = ttk.Button(control_frame, text="☀️" if self.dark_mode else "🌙", command=self.toggle_theme, width=3)
self.theme_button.pack(side=RIGHT)
# Update swatch initially
self.update_color_swatch()
def update_color_swatch(self):
"""Updates the background color preview swatch."""
try:
color = self.bg_var.get()
# Ensure valid color string before applying
self.winfo_rgb(color) # Check if color is valid
self.bg_swatch.config(bg=color,
# Match border style to theme
highlightbackground=self.BLENDER_THEME["border"] if self.dark_mode else "gray",
highlightcolor=self.BLENDER_THEME["border"] if self.dark_mode else "gray",
highlightthickness=1)
except TclError:
# Handle invalid color string temporarily entered
self.bg_swatch.config(bg=self.BLENDER_THEME["input_bg"] if self.dark_mode else "white") # Default fallback
def update_theme(self):
"""Update the theme based on dark mode setting"""
theme_to_use = 'clam' # Default light theme
if self.dark_mode:
theme_created = self.create_blender_theme()
if theme_created and 'blenderbim' in self.style.theme_names():
theme_to_use = 'blenderbim'
print("Using BlenderBIM dark theme.")
else:
theme_to_use = 'alt' # Fallback dark theme
print("BlenderBIM theme failed or not found, falling back to 'alt'.")
self.log("Failed to apply custom dark theme, using fallback.", "warning")
# Apply ttk theme
self.style.theme_use(theme_to_use)
# --- Manual configuration for Dark Mode ---
# Configure root window background
self.configure(background=self.BLENDER_THEME["bg"])
# Configure standard Frames (like main_frame) if needed
# self.nametowidget("main_frame").configure(background=self.BLENDER_THEME["bg"]) # Can cause issues if frames inside have different bg
# Configure non-ttk widgets to match Blender theme
listbox_bg = self.BLENDER_THEME["input_bg"]
listbox_fg = self.BLENDER_THEME["text_input"]
listbox_select_bg = self.BLENDER_THEME["highlight"]
listbox_border = self.BLENDER_THEME["border"]
self.file_list.configure(
background=listbox_bg,
foreground=listbox_fg,
selectbackground=listbox_select_bg,
selectforeground=listbox_fg, # Keep text color same on selection
relief="solid",
borderwidth=1,
highlightthickness=1, # Use highlightthickness for border on non-ttk
highlightbackground=listbox_border, # Color when not focused
highlightcolor=listbox_border # Color when focused
)
log_bg = self.BLENDER_THEME["input_bg"]
log_fg = self.BLENDER_THEME["text_input"]
log_select_bg = self.BLENDER_THEME["highlight"]
log_border = self.BLENDER_THEME["border"]
self.log_area.configure(
background=log_bg,
foreground=log_fg,
insertbackground=self.BLENDER_THEME["text"], # Cursor color
selectbackground=log_select_bg,
selectforeground=log_fg, # Keep text color same on selection
relief="solid",
borderwidth=1,
highlightthickness=1,
highlightbackground=log_border,
highlightcolor=log_border,
# Ensure scrollbar frame matches
# wrap=WORD # Already set
)
# Style the frame holding the ScrolledText's scrollbar
# This is internal to ScrolledText, might be fragile
try:
# Access the frame Tkinter automatically creates around the Text widget
log_text_widget = self.log_area.winfo_children()[0] # Should be the Text widget
log_text_widget.configure(background=log_bg, foreground=log_fg, insertbackground=self.BLENDER_THEME["text"])
# The scrollbar is usually a sibling within the ScrolledText frame
# We rely on ttk theme for scrollbar styling here
except Exception as e:
print(f"Minor issue styling log area internals: {e}")
# Update tag colors for dark mode readability
self.log_area.tag_config("info", foreground="#77b0ff") # Lighter blue
self.log_area.tag_config("success", foreground="#77dd77") # Lighter green
self.log_area.tag_config("warning", foreground="#ffcc66") # Lighter orange/yellow
self.log_area.tag_config("error", foreground="#ff7777") # Lighter red
# Update theme button text
self.theme_button.config(text="☀️")
else: # --- Light Mode ---
theme_to_use = 'clam' # Use clam for light mode
self.style.theme_use(theme_to_use)
print(f"Using {theme_to_use} light theme.")
# Configure root window background to system default
self.configure(background='SystemButtonFace')
# Configure non-ttk widgets for standard light theme look
listbox_bg = 'white'
listbox_fg = 'black'
listbox_select_bg = '#0078d7' # Standard Windows blue selection
listbox_select_fg = 'white'
listbox_border = 'gray'
self.file_list.configure(
background=listbox_bg,
foreground=listbox_fg,
selectbackground=listbox_select_bg,
selectforeground=listbox_select_fg,
relief="solid",
borderwidth=1,
highlightthickness=1,
highlightbackground=listbox_border,
highlightcolor=listbox_border
)
log_bg = 'white'
log_fg = 'black'
log_select_bg = '#0078d7'
log_select_fg = 'white'
log_border = 'gray'
self.log_area.configure(
background=log_bg,
foreground=log_fg,
insertbackground=log_fg, # Black cursor
selectbackground=log_select_bg,
selectforeground=log_select_fg,
relief="solid",
borderwidth=1,
highlightthickness=1,
highlightbackground=log_border,
highlightcolor=log_border
)
# Style the frame holding the ScrolledText's scrollbar
try:
log_text_widget = self.log_area.winfo_children()[0]
log_text_widget.configure(background=log_bg, foreground=log_fg, insertbackground=log_fg)
except Exception as e:
print(f"Minor issue styling log area internals (light): {e}")
# Update tag colors for light mode
self.log_area.tag_config("info", foreground="blue")
self.log_area.tag_config("success", foreground="green")
self.log_area.tag_config("warning", foreground="#E69900") # Darker orange
self.log_area.tag_config("error", foreground="red")
# Update theme button text
self.theme_button.config(text="🌙")
# Update color swatch after theme change
self.update_color_swatch()
# Force redraw of the whole window to apply changes
self.update_idletasks()
def toggle_theme(self):
"""Toggle between dark (BlenderBIM) and light (clam) mode"""
self.dark_mode = not self.dark_mode
self.log(f"Switched to {'Dark (BlenderBIM)' if self.dark_mode else 'Light (Clam)'} Mode", "info")
self.update_theme()
# Settings are saved on close
def load_settings(self):
"""Load settings from file"""
default_settings = {
'dpi': 96,
'background': '#FFFFFF',
'format': 'png',
'output_dir': 'Same as source',
'overwrite': False,
'auto_open': False,
'window_geometry': '900x700',
'dark_mode': self._detect_dark_mode(),
'inkscape_path': None # Store user-provided path
}
try:
if self.settings_file.exists():
with open(self.settings_file, 'r') as f:
loaded_settings = json.load(f)
self.settings = {**default_settings, **loaded_settings}
# Allow saved dark_mode preference to override detection *if* darkdetect fails or is absent
if 'force_dark_mode' in loaded_settings:
self.settings['dark_mode'] = loaded_settings['force_dark_mode']
else: # Otherwise re-detect
self.settings['dark_mode'] = self._detect_dark_mode()
else:
self.settings = default_settings
self.log("No settings file found, using defaults.", "info")
self.dark_mode = self.settings.get('dark_mode', False)
self.geometry(self.settings.get('window_geometry', '900x700'))
# Use saved inkscape path if available
self.inkscape_path = self.settings.get('inkscape_path', None)
except Exception as e:
self.log(f"Error loading settings: {str(e)}. Using defaults.", "error")
self.settings = default_settings
self.dark_mode = self.settings.get('dark_mode', False)
self.geometry(self.settings.get('window_geometry', '900x700'))
self.inkscape_path = None
def save_settings(self):
"""Save current settings to file"""
settings_to_save = {}
# Ensure UI elements exist before saving from them
if hasattr(self, 'dpi_var'): settings_to_save['dpi'] = self.dpi_var.get()
if hasattr(self, 'bg_var'): settings_to_save['background'] = self.bg_var.get()
if hasattr(self, 'format_var'): settings_to_save['format'] = self.format_var.get()
if hasattr(self, 'output_var'): settings_to_save['output_dir'] = self.output_var.get()
if hasattr(self, 'overwrite_var'): settings_to_save['overwrite'] = self.overwrite_var.get()
if hasattr(self, 'auto_open_var'): settings_to_save['auto_open'] = self.auto_open_var.get()
try: settings_to_save['window_geometry'] = self.geometry()
except TclError: pass # Window might be destroyed
# Save the user's *current* dark mode choice
settings_to_save['force_dark_mode'] = self.dark_mode
# Save the inkscape path (might be None or user-provided)
settings_to_save['inkscape_path'] = self.inkscape_path
# Merge with existing settings to preserve any unknown keys
self.settings.update(settings_to_save)
try:
with open(self.settings_file, 'w') as f:
json.dump(self.settings, f, indent=4)
except Exception as e:
# Log to console as GUI might be closing
print(f"Error saving settings: {str(e)}")
def find_inkscape(self):
"""Find Inkscape executable, using saved path first."""
# 0. Use path from settings if it exists and is valid
if self.inkscape_path and Path(self.inkscape_path).is_file():
print(f"Using Inkscape path from settings: {self.inkscape_path}")
return self.inkscape_path
# 1. Check PATH
inkscape_exe = "inkscape.exe" if platform.system() == "Windows" else "inkscape"
try:
cmd = ["where", "inkscape"] if platform.system() == "Windows" else ["which", "inkscape"]
# Prevent console window pop-up on Windows when running 'where'
startupinfo = None
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
result = subprocess.run(cmd, capture_output=True, text=True, check=False, startupinfo=startupinfo)
if result.returncode == 0:
paths = result.stdout.splitlines()
for p in paths:
p_strip = p.strip()
if p_strip and Path(p_strip).is_file() and p_strip.lower().endswith(inkscape_exe.lower()):
self.log(f"Found Inkscape in PATH: {p_strip}", "info")
self.inkscape_path = p_strip # Save found path
return p_strip
except Exception as e:
self.log(f"Error checking PATH for Inkscape: {e}", "warning")
# 2. Check common locations
common_paths = []
# (Same common paths as before)
if platform.system() == "Windows":
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
program_files_x86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")
local_app_data = os.environ.get("LocalAppData", "")
common_paths.extend([
Path(program_files) / "Inkscape" / "bin" / inkscape_exe,
Path(program_files_x86) / "Inkscape" / "bin" / inkscape_exe,
Path(local_app_data) / "Programs" / "Inkscape" / "bin" / inkscape_exe if local_app_data else None,
Path(program_files) / "Inkscape" / inkscape_exe, # Older?
])
elif platform.system() == "Darwin": # macOS
common_paths.extend([
Path("/Applications/Inkscape.app/Contents/MacOS/inkscape"),
Path("/usr/local/bin/inkscape"), # Homebrew
Path("/opt/homebrew/bin/inkscape"), # Apple Silicon Homebrew
])
else: # Linux
common_paths.extend([
Path("/usr/bin/inkscape"),
Path("/usr/local/bin/inkscape"),
Path("/snap/bin/inkscape"),
Path(os.path.expanduser("~/.local/bin/inkscape")),
Path("/app/bin/inkscape"), # Flatpak
Path("/opt/inkscape/bin/inkscape"),
])
for path in filter(None, common_paths):
if path.exists() and path.is_file():
self.log(f"Found Inkscape via common path: {path}", "info")
self.inkscape_path = str(path) # Save found path
return str(path)
# 3. Ask user if still not found
self.log("Inkscape not found automatically.", "warning")
if messagebox.askyesno("Inkscape Not Found", "Could not automatically find the Inkscape executable.\n\nWould you like to locate it manually?", parent=self):
exe_filter = [("Executable files", f"inkscape{'.exe' if platform.system() == 'Windows' else ''}"), ("All files", "*.*")]
manual_path = filedialog.askopenfilename(parent=self, title="Locate Inkscape Executable", filetypes=exe_filter)
if manual_path and Path(manual_path).is_file():
self.log(f"User provided Inkscape path: {manual_path}", "info")
self.inkscape_path = manual_path # Save user path
# No need to save settings here, saved on close
return manual_path
# If user cancels or provides invalid path
self.inkscape_path = None
return None
# --- Log, File Handling, Conversion Logic (Mostly unchanged) ---
def log(self, message, level="info"):
"""Add a message to the log area"""
try:
self.log_area.configure(state='normal')
self.log_area.insert(END, f"[{level.upper()}] {message}\n", level)
self.log_area.configure(state='disabled')
self.log_area.see(END)
self.update_idletasks()
except Exception as e:
print(f"Error logging message '{message}': {e}") # Fallback
def clear_log(self):
"""Clear the log area"""
self.log_area.configure(state='normal')
self.log_area.delete(1.0, END)
self.log_area.configure(state='disabled')
self.log("Log cleared.", "info")
def add_files(self):
"""Add files through file dialog"""
filetypes = [("SVG Files", "*.svg"), ("All Files", "*.*")]
files = filedialog.askopenfilenames(parent=self, title="Select SVG Files", filetypes=filetypes)
if files: self.update_file_list(files)
def add_folder(self):
"""Add all SVG files from a folder (non-recursive)"""
folder = filedialog.askdirectory(parent=self, title="Select Folder Containing SVG Files")
if folder:
svg_files = []
try:
for item in os.listdir(folder):
item_path = os.path.join(folder, item)
if os.path.isfile(item_path) and item.lower().endswith('.svg'):
svg_files.append(item_path)
except Exception as e: self.log(f"Error reading folder {folder}: {e}", "error"); return
if svg_files:
self.log(f"Found {len(svg_files)} SVG files in {folder}.", "info"); self.update_file_list(svg_files)
else: self.log(f"No SVG files found directly in {folder}.", "warning")
def handle_drop(self, event):
"""Handle files/folders dropped onto the window"""
try: files_or_folders = self.tk.splitlist(event.data)
except Exception: files_or_folders = [event.data]
valid_files = []
for item_path in files_or_folders:
path = Path(item_path)
if path.is_dir():
self.log(f"Scanning dropped folder: {path}", "info")
try:
for sub_item in path.iterdir():
if sub_item.is_file() and sub_item.suffix.lower() == '.svg': valid_files.append(str(sub_item))
except Exception as e: self.log(f"Error scanning dropped folder {path}: {e}", "error")
elif path.is_file() and path.suffix.lower() == '.svg': valid_files.append(str(path))
else: self.log(f"Ignoring dropped non-SVG item: {item_path}", "warning")
if valid_files: self.update_file_list(valid_files)
else: self.log("No valid SVG files found in dropped items.", "warning")
def update_file_list(self, new_files):
"""Update the file list, avoiding duplicates"""
existing_files = set(self.selected_files)
added_count = 0
for file in new_files:
try:
file_path = str(Path(file).resolve())
if file_path not in existing_files:
self.selected_files.append(file_path); existing_files.add(file_path); added_count += 1
except Exception as e: self.log(f"Error processing file path '{file}': {e}", "warning")
if added_count > 0: self.refresh_file_list(); self.log(f"Added {added_count} file(s). Total: {len(self.selected_files)}", "info")
elif new_files: self.log("No new files added (duplicates ignored).", "info")
def refresh_file_list(self):
"""Refresh the file list display"""
self.file_list.delete(0, END)
# self.selected_files.sort() # Optional sort
for file_path in self.selected_files: self.file_list.insert(END, file_path)
def remove_files(self):
"""Remove selected files from the list"""
selected_indices = self.file_list.curselection()
if not selected_indices: self.log("No files selected to remove.", "warning"); return
removed_count = 0
for i in sorted(selected_indices, reverse=True):
try: self.selected_files.pop(i); self.file_list.delete(i); removed_count += 1
except IndexError: self.log(f"Error removing index {i}.", "error")
if removed_count > 0: self.log(f"Removed {removed_count} file(s).", "info")
def clear_files(self):
"""Clear all files from the list"""
if not self.selected_files: self.log("File list is already empty.", "info"); return
count = len(self.selected_files)
self.selected_files.clear(); self.refresh_file_list()
self.log(f"Cleared all {count} files.", "info")
def pick_color(self):
"""Open color picker dialog"""
initial_color = self.bg_var.get()
result = colorchooser.askcolor(parent=self, title="Select Background Color", initialcolor=initial_color)
if result and result[1]: self.bg_var.set(result[1].upper()); self.update_color_swatch()
def choose_output_dir(self):
"""Choose output directory"""
initial_dir = self.output_var.get()
if initial_dir.lower() == 'same as source' or not os.path.isdir(initial_dir): initial_dir = os.getcwd()
directory = filedialog.askdirectory(parent=self, title="Select Output Directory", initialdir=initial_dir)
if directory: self.output_var.set(directory)
def set_ui_state(self, enabled: bool):
"""Enable or disable UI elements during conversion."""
state = 'normal' if enabled else 'disabled'
widgets_to_toggle = [
self.convert_button, self.retry_button, self.clear_log_button, self.theme_button,
self.file_list # Listbox uses 'disabled' state correctly
]
# Toggle all children within settings and file frames
for frame in [self.winfo_children()[0].winfo_children()[0], self.winfo_children()[0].winfo_children()[1]]: # File frame, Settings frame
for widget in frame.winfo_children():
# Check if widget supports 'state' configuration
if isinstance(widget, (ttk.Button, ttk.Entry, ttk.Spinbox, ttk.Combobox, ttk.Checkbutton, ttk.Scrollbar, Listbox)):
widgets_to_toggle.append(widget)
elif isinstance(widget, Frame): # Recurse into standard frames if needed (like bg_frame)
for sub_widget in widget.winfo_children():
if isinstance(sub_widget, (ttk.Button, ttk.Entry, Label)): # Include Label swatch
widgets_to_toggle.append(sub_widget)
for widget in widgets_to_toggle:
try:
# Special handling for retry button
if widget == self.retry_button:
widget.config(state='normal' if enabled and self.failed_files else 'disabled')
else:
widget.config(state=state)
except (TclError, AttributeError): pass # Ignore widgets without 'state' or if already destroyed
def start_conversion(self):
"""Start the conversion process"""
if self.conversion_running: self.log("Conversion already running.", "warning"); return
if not self.selected_files: self.log("No files selected.", "warning"); messagebox.showwarning("No Files", "Add SVG files first.", parent=self); return
# Re-check Inkscape *before* starting thread
current_inkscape_path = self.find_inkscape()
if not current_inkscape_path:
self.log("Cannot start: Inkscape path not valid.", "error")
messagebox.showerror("Inkscape Error", "Inkscape not found or path is invalid.\nPlease ensure Inkscape is installed and locate it if prompted.", parent=self)
return
self.inkscape_path = current_inkscape_path # Ensure instance variable is up-to-date
self.current_settings = {
'dpi': self.dpi_var.get(), 'background': self.bg_var.get(), 'format': self.format_var.get(),
'output_dir_setting': self.output_var.get(), 'overwrite': self.overwrite_var.get(),
'auto_open': self.auto_open_var.get()
}
self.conversion_running = True; self.failed_files.clear(); self.progress_var.set(0)
self.status_var.set("Starting conversion..."); self.set_ui_state(False)
self.log(f"--- Starting conversion ({len(self.selected_files)} files) ---", "info")
self.conversion_thread = threading.Thread(target=self.convert_files_thread, daemon=True)
self.conversion_thread.start()
def convert_files_thread(self):
"""Worker thread for converting files"""
total_files = len(self.selected_files); success_count = 0; first_output_dir = None
files_to_process = list(self.selected_files) # Local copy
for i, input_file in enumerate(files_to_process, 1):
if not self.conversion_running: self.log("Conversion cancelled.", "warning"); break
base_name = os.path.basename(input_file)
self.status_var.set(f"Converting {i}/{total_files}: {base_name}")
# self.log(f"Processing ({i}/{total_files}): {input_file}", "info") # Can be noisy
try:
output_path_obj, output_dir = self.get_output_path(input_file, self.current_settings)
output_file = str(output_path_obj)
if first_output_dir is None and output_dir: first_output_dir = output_dir
if not self.current_settings['overwrite'] and output_path_obj.exists():
self.log(f"Skipped (exists): {base_name} -> {output_file}", "warning"); continue
try: output_dir.mkdir(parents=True, exist_ok=True)
except OSError as e: self.log(f"Error creating dir {output_dir}: {e}. Skipping.", "error"); self.failed_files.append(input_file); continue
success = self.convert_single_file(input_file, output_file, self.current_settings)
if success: success_count += 1 #; self.log(f"Success: {base_name} -> {output_file}", "success") # Log success can be noisy
else: self.failed_files.append(input_file); self.log(f"Failed: {base_name}", "error") # Error already logged
except Exception as e: self.failed_files.append(input_file); self.log(f"Error processing {base_name}: {e}", "error")
self.progress_var.set((i / total_files) * 100)
# --- Post Conversion ---
final_message = f"Complete: {success_count} succeeded, {len(self.failed_files)} failed."
self.status_var.set(final_message)
if not self.failed_files and success_count > 0: self.log(f"--- All {success_count} files converted successfully! ---", "success")
elif self.failed_files:
self.log(f"--- Conversion finished with {len(self.failed_files)} failures. ---", "warning")
# Optionally list failed files again here if needed
elif success_count == 0 and not self.failed_files: self.log("--- Finished. No files processed (all skipped?). ---", "info")
else: self.log("--- Conversion finished. ---", "info")
if success_count > 0 and self.current_settings['auto_open'] and first_output_dir:
self.log(f"Opening output folder: {first_output_dir}", "info")
self.open_output_folder(str(first_output_dir))
self.after(100, self._finalize_conversion)
def _finalize_conversion(self):
"""Actions on main thread after conversion finishes."""
self.conversion_running = False
self.set_ui_state(True) # Re-enable UI
self.progress_var.set(100 if not self.failed_files else self.progress_var.get()) # Show 100% or final progress
def get_output_path(self, input_file, settings):
"""Determine output Path object and directory Path."""
input_path = Path(input_file); output_dir_setting = settings['output_dir_setting']
output_dir = input_path.parent if output_dir_setting.lower() == 'same as source' else Path(output_dir_setting)
fmt = settings['format'].lower()
valid_formats = ['png', 'jpg', 'jpeg', 'pdf', 'tiff', 'tif', 'eps', 'svg', 'ps', 'webp']
if fmt not in valid_formats: fmt = 'png'; self.log(f"Invalid format, using 'png'.", "warning")
fmt = 'jpg' if fmt == 'jpeg' else 'tiff' if fmt == 'tif' else fmt # Normalize
output_filename = f"{input_path.stem}.{fmt}"
return output_dir / output_filename, output_dir
def convert_single_file(self, input_file, output_file, settings):
"""Convert a single file using Inkscape CLI."""
if not self.inkscape_path:
self.log("Inkscape path missing.", "error")
return False
cmd = [
self.inkscape_path, input_file,
f"--export-filename={output_file}",
f"--export-type={settings['format']}",
f"--export-dpi={settings['dpi']}",
]
if settings['format'].lower() in ['png', 'jpg', 'jpeg', 'tiff', 'tif', 'webp']: # Background for raster formats
cmd.append(f"--export-background={settings['background']}")
# cmd.append("--export-background-opacity=1.0") # Ensure full opacity
# self.log(f"CMD: {' '.join(cmd)}", "info") # Verbose logging
try:
startupinfo = None
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
result = subprocess.run(cmd, capture_output=True, text=True, check=False,
encoding='utf-8', errors='replace', startupinfo=startupinfo, timeout=120) # Add timeout (e.g., 120 seconds)
if result.returncode != 0:
self.log(f"Inkscape error (code {result.returncode}) for {os.path.basename(input_file)}.", "error")
stderr_lines = result.stderr.strip().splitlines()
stdout_lines = result.stdout.strip().splitlines()
if stderr_lines:
self.log(f" stderr: {' / '.join(stderr_lines[-3:])}", "error") # Log last few lines
if stdout_lines:
self.log(f" stdout: {' / '.join(stdout_lines[-3:])}", "info")
# Attempt delete incomplete file
try:
if Path(output_file).exists():
Path(output_file).unlink()
except Exception:
pass
return False
if result.stderr: # Log warnings even on success
warnings = [line for line in result.stderr.strip().splitlines() if 'warning' in line.lower()]
if warnings:
self.log(f"Inkscape warnings for {os.path.basename(input_file)}: {' / '.join(warnings[:2])}", "warning")
out_path = Path(output_file)
if not out_path.exists():
self.log(f"Output file missing after Inkscape success: {output_file}", "error")
return False
if out_path.stat().st_size == 0:
self.log(f"Output file is empty: {output_file}", "error")
try:
out_path.unlink()
except:
pass
return False
return True
except FileNotFoundError:
self.log(f"Inkscape command not found at '{self.inkscape_path}'.", "error")
self.conversion_running = False
self.after(100, self._finalize_conversion)
return False
except subprocess.TimeoutExpired:
self.log(f"Inkscape timed out for {os.path.basename(input_file)}.", "error")
return False
except Exception as e:
self.log(f"Inkscape execution error for {os.path.basename(input_file)}: {e}", "error")
import traceback
self.log(traceback.format_exc(), "error")
return False
def retry_failed(self):
"""Retry conversion of failed files"""
if not self.failed_files: self.log("No failed files to retry.", "info"); messagebox.showinfo("Retry Failed", "No files failed in the last run.", parent=self); return
if self.conversion_running: self.log("Cannot retry while conversion running.", "warning"); return
self.log(f"--- Retrying {len(self.failed_files)} failed file(s) ---", "info")
self.selected_files = list(self.failed_files); self.refresh_file_list()
self.start_conversion()
def open_output_folder(self, directory):
"""Open the output folder in the system file manager"""
path = Path(directory)
if not path.is_dir(): self.log(f"Cannot open folder (not found): '{directory}'", "warning"); return
try:
if platform.system() == "Windows": os.startfile(path)
elif platform.system() == "Darwin": subprocess.run(["open", str(path)], check=True)
else: subprocess.run(["xdg-open", str(path)], check=True)
except Exception as e: self.log(f"Could not open output folder '{directory}': {e}", "error"); messagebox.showerror("Error", f"Failed to open folder:\n{directory}\n\nError: {e}", parent=self)
def on_close(self):
"""Handle window close event"""
if self.conversion_running:
if messagebox.askokcancel("Conversion in Progress", "Conversion is running. Quit anyway?", parent=self):
self.log("User aborted via window close.", "warning"); self.conversion_running = False
# Settings save might fail if thread is mid-file-write, but try anyway
self.save_settings(); self.destroy()
else: return # Don't close
else:
self.save_settings(); self.destroy()
if __name__ == "__main__":
# Set DPI awareness (Windows)
try:
if platform.system() == "Windows": from ctypes import windll; windll.shcore.SetProcessDpiAwareness(1)
except Exception as e: print(f"Note: Could not set DPI awareness: {e}")
app = SVGtoPNGConverter()
app.mainloop()