svg-processor/gui/element_mapping_frame.py
2025-05-16 18:15:31 +04:00

461 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import tkinter as tk
from tkinter import ttk
import time # For debouncing
DEFAULT_MAPPING = {
'svg_type': "rect", 'label_prefix': "", 'element_type': "ia.display.view",
'props_path': "Symbol-Views/Equipment-Views/Status", 'width': "14", 'height': "14",
'x_offset': "0", 'y_offset': "0", 'final_prefix': "", 'final_suffix': ""
}
class ElementMappingFrame(ttk.Frame):
"""Frame for managing SVG element mappings, including add/remove and saving."""
def __init__(self, parent, save_config_callback=None, default_mappings=None, **kwargs):
"""
Initialize the Element Mapping Frame.
Args:
parent: The parent widget.
save_config_callback (callable, optional): Function to call (debounced) when mappings change.
default_mappings (list, optional): Default mappings to load if none exist.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, **kwargs)
self._save_config_callback = save_config_callback
self._default_mappings = default_mappings if default_mappings is not None else [DEFAULT_MAPPING]
# Internal state
self.mapping_rows = [] # List storing dicts: {'vars': {}, 'widgets': {}}
self._initialized = False # Flag to prevent saving during initial load
self._save_timer_id = None
self._canvas = None
self._scrollable_frame = None
self._create_widgets()
self._setup_bindings()
def _create_widgets(self):
"""Create the main widgets: Canvas, Scrollbar, inner frame, header, add button."""
# --- Container for Add Button + Scrollable Area ---
main_container = ttk.Frame(self)
main_container.pack(fill=tk.BOTH, expand=True)
main_container.rowconfigure(1, weight=1)
main_container.columnconfigure(0, weight=1)
# --- Add Button ---
add_button_frame = ttk.Frame(main_container, padding=(0, 5))
add_button_frame.grid(row=0, column=0, sticky=tk.EW)
add_button = ttk.Button(add_button_frame, text="Add New Mapping", command=self._handle_add_new_mapping_click)
add_button.pack(side=tk.LEFT)
# Add more buttons here later if needed (e.g., Load Defaults)
# --- Scrollable Area ---
scroll_container = ttk.Frame(main_container)
scroll_container.grid(row=1, column=0, sticky="nsew")
scroll_container.rowconfigure(0, weight=1)
scroll_container.columnconfigure(0, weight=1)
self._canvas = tk.Canvas(scroll_container, borderwidth=0, highlightthickness=0)
scrollbar = ttk.Scrollbar(scroll_container, orient="vertical", command=self._canvas.yview)
self._scrollable_frame = ttk.Frame(self._canvas) # The frame holding the mapping grid
self._scrollable_frame.bind("<Configure>", self._on_frame_configure)
self._canvas.create_window((0, 0), window=self._scrollable_frame, anchor="nw", tags="self.frame")
self._canvas.configure(yscrollcommand=scrollbar.set)
self._canvas.grid(row=0, column=0, sticky="nsew")
scrollbar.grid(row=0, column=1, sticky="ns")
# --- Header Row (in scrollable frame) ---
self._create_header_row(self._scrollable_frame)
def _on_frame_configure(self, event=None):
"""Reset the scroll region to encompass the inner frame."""
if self._canvas:
self._canvas.configure(scrollregion=self._canvas.bbox("all"))
def _create_header_row(self, parent):
"""Create the header row with column labels."""
headers = [
("SVG Type", 10), ("Label Prefix", 10), ("Element Type", 20), ("Props Path", 40),
("Size (WxH)", 10), ("Offset (X,Y)", 10), ("Final Prefix", 15), ("Final Suffix", 15), ("", 3) # Action
]
for col, (header, width) in enumerate(headers):
lbl = ttk.Label(parent, text=header, font=('TkDefaultFont', 9, 'bold'))
lbl.grid(row=0, column=col, padx=5, pady=(2,5), sticky="w")
parent.columnconfigure(col, weight=0, minsize=width*5) # Basic width estimate
parent.columnconfigure(3, weight=2) # Allow Props Path to expand most (increased weight)
parent.columnconfigure(2, weight=1)
def _setup_bindings(self):
"""Set up mouse wheel scrolling for the canvas."""
# Bind to canvas and scrollable frame to catch events inside
for widget in [self._canvas, self._scrollable_frame]:
# Unix-like
widget.bind("<Button-4>", self._on_mousewheel, add='+')
widget.bind("<Button-5>", self._on_mousewheel, add='+')
# Windows
widget.bind("<MouseWheel>", self._on_mousewheel, add='+')
def _on_mousewheel(self, event):
"""Handle mouse wheel scrolling only when mouse is over the canvas."""
# Check if the mouse is within the canvas bounds
canvas_x = self._canvas.winfo_rootx()
canvas_y = self._canvas.winfo_rooty()
canvas_w = self._canvas.winfo_width()
canvas_h = self._canvas.winfo_height()
if not (canvas_x <= event.x_root < canvas_x + canvas_w and
canvas_y <= event.y_root < canvas_y + canvas_h):
return # Mouse not over canvas
if event.num == 5 or event.delta < 0:
self._canvas.yview_scroll(1, "units")
elif event.num == 4 or event.delta > 0:
self._canvas.yview_scroll(-1, "units")
# Optional: return "break" to prevent event propagation further?
# return "break"
def _add_mapping_row(self, data=None):
"""Adds a new row for element mapping to the UI and internal list."""
if data is None: data = {}
row_index_in_grid = len(self.mapping_rows) + 1 # Grid row (1-based below header)
list_index = len(self.mapping_rows) # Index in self.mapping_rows list
row_vars = {
'svg_type': tk.StringVar(value=data.get('svg_type', '')),
'label_prefix': tk.StringVar(value=data.get('label_prefix', '')),
'element_type': tk.StringVar(value=data.get('element_type', '')),
'props_path': tk.StringVar(value=data.get('props_path', '')),
'width': tk.StringVar(value=str(data.get('width', ''))),
'height': tk.StringVar(value=str(data.get('height', ''))),
'x_offset': tk.StringVar(value=str(data.get('x_offset', ''))),
'y_offset': tk.StringVar(value=str(data.get('y_offset', ''))),
'final_prefix': tk.StringVar(value=data.get('final_prefix', '')),
'final_suffix': tk.StringVar(value=data.get('final_suffix', '')),
}
widgets = {}
pad_options = {'pady': 1, 'padx': 5}
entry_width = 10 # Default, will be overridden by column config mostly
# Create Entry Widgets
widgets['svg_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['svg_type'], width=entry_width)
widgets['svg_entry'].grid(row=row_index_in_grid, column=0, sticky=tk.EW, **pad_options)
widgets['label_prefix_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['label_prefix'], width=entry_width)
widgets['label_prefix_entry'].grid(row=row_index_in_grid, column=1, sticky=tk.EW, **pad_options)
widgets['element_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['element_type'], width=entry_width*2)
widgets['element_entry'].grid(row=row_index_in_grid, column=2, sticky=tk.EW, **pad_options)
widgets['props_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['props_path'], width=entry_width*6)
widgets['props_entry'].grid(row=row_index_in_grid, column=3, sticky=tk.EW, **pad_options)
# --- Size Frame (WxH) ---
size_frame = ttk.Frame(self._scrollable_frame)
size_frame.grid(row=row_index_in_grid, column=4, sticky=tk.EW, **pad_options)
widgets['width_entry'] = ttk.Entry(size_frame, textvariable=row_vars['width'], width=5)
widgets['width_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0,1))
ttk.Label(size_frame, text="×").pack(side=tk.LEFT)
widgets['height_entry'] = ttk.Entry(size_frame, textvariable=row_vars['height'], width=5)
widgets['height_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(1,0))
widgets['size_frame'] = size_frame
# --- Offset Frame (X,Y) ---
offset_frame = ttk.Frame(self._scrollable_frame)
offset_frame.grid(row=row_index_in_grid, column=5, sticky=tk.EW, **pad_options)
widgets['x_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['x_offset'], width=5)
widgets['x_offset_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0,1))
ttk.Label(offset_frame, text=",").pack(side=tk.LEFT)
widgets['y_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['y_offset'], width=5)
widgets['y_offset_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(1,0))
widgets['offset_frame'] = offset_frame
# --- Final Prefix/Suffix ---
widgets['final_prefix_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['final_prefix'], width=entry_width+5)
widgets['final_prefix_entry'].grid(row=row_index_in_grid, column=6, sticky=tk.EW, **pad_options)
widgets['final_suffix_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['final_suffix'], width=entry_width+5)
widgets['final_suffix_entry'].grid(row=row_index_in_grid, column=7, sticky=tk.EW, **pad_options)
# --- Remove Button ---
remove_button = ttk.Button(self._scrollable_frame, text="X", width=3, style="Danger.TButton",
command=lambda idx=list_index: self._handle_remove_mapping_click(idx))
remove_button.grid(row=row_index_in_grid, column=8, sticky=tk.W, pady=1, padx=(5, 15))
widgets['remove_button'] = remove_button
# Store row information
self.mapping_rows.append({'vars': row_vars, 'widgets': widgets})
# Add trace to trigger save when any value changes (AFTER adding to list)
for var in row_vars.values():
var.trace_add("write", self._schedule_config_save)
# Update scroll region after adding row
self._on_frame_configure()
# Scroll to bottom if added by button click (heuristic)
# if self._initialized: self._scroll_to_bottom()
return list_index # Return the list index
def _handle_add_new_mapping_click(self):
"""Callback for the 'Add New Mapping' button."""
new_index = self._add_mapping_row() # Add blank row
# Focus on the first entry of the new row and scroll
if new_index is not None and new_index < len(self.mapping_rows):
try:
self.mapping_rows[new_index]['widgets']['svg_entry'].focus_set()
self._scroll_to_show_row(new_index)
except tk.TclError: pass # Ignore if widget destroyed
self._schedule_config_save() # Trigger save after adding
def _scroll_to_show_row(self, list_index):
"""Scrolls the canvas so the specified row (by list index) is visible."""
try:
widget_to_show = self.mapping_rows[list_index]['widgets']['svg_entry']
self.update_idletasks() # Ensure layout calculated
self._canvas.yview_scroll(0, "pages") # Go to top first for better bbox calc?
bbox = self._canvas.bbox(widget_to_show) # Get bbox relative to canvas
if bbox:
canvas_height = self._canvas.winfo_height()
# Check if bottom of widget is below the visible area
if bbox[3] > self._canvas.canvasy(0) + canvas_height:
self._canvas.yview_moveto(bbox[1] / self._canvas.bbox("all")[3]) # Scroll based on top edge
# Check if top of widget is above the visible area
elif bbox[1] < self._canvas.canvasy(0):
self._canvas.yview_moveto(bbox[1] / self._canvas.bbox("all")[3])
except (IndexError, KeyError, tk.TclError) as e:
print(f"Error scrolling to row {list_index}: {e}")
def _handle_remove_mapping_click(self, list_index):
"""Removes the row and schedules a save."""
self._remove_mapping_row(list_index)
self._schedule_config_save() # Trigger save after removal
def _remove_mapping_row(self, list_index):
"""Removes a mapping row from the list and destroys its widgets."""
if not (0 <= list_index < len(self.mapping_rows)):
print(f"Error: Invalid index {list_index} for removing mapping row.")
return
row_to_remove = self.mapping_rows.pop(list_index)
# Destroy Widgets
for widget in row_to_remove['widgets'].values():
if widget and isinstance(widget, tk.Widget):
try:
widget.destroy()
except tk.TclError: pass # Ignore if already destroyed
# Re-grid the remaining rows below the removed one
self._re_grid_rows(start_list_index=list_index)
self._on_frame_configure() # Update scroll region
# If list is now empty, add a default row (but don't save it yet)
if not self.mapping_rows:
# Temporarily disable save callback during this add
original_callback = self._save_config_callback
self._save_config_callback = None
self._add_mapping_row(DEFAULT_MAPPING)
self._save_config_callback = original_callback
def _re_grid_rows(self, start_list_index=0):
"""Re-grids rows in the UI from the given list index onwards."""
for idx in range(start_list_index, len(self.mapping_rows)):
row_data = self.mapping_rows[idx]
new_grid_row = idx + 1 # Grid row is 1-based
widgets = row_data['widgets']
pad_options = {'pady': 1, 'padx': 5}
# --- Re-Grid all widgets ---
widgets['svg_entry'].grid(row=new_grid_row, column=0, sticky=tk.EW, **pad_options)
widgets['label_prefix_entry'].grid(row=new_grid_row, column=1, sticky=tk.EW, **pad_options)
widgets['element_entry'].grid(row=new_grid_row, column=2, sticky=tk.EW, **pad_options)
widgets['props_entry'].grid(row=new_grid_row, column=3, sticky=tk.EW, **pad_options)
widgets['size_frame'].grid(row=new_grid_row, column=4, sticky=tk.EW, **pad_options)
widgets['offset_frame'].grid(row=new_grid_row, column=5, sticky=tk.EW, **pad_options)
widgets['final_prefix_entry'].grid(row=new_grid_row, column=6, sticky=tk.EW, **pad_options)
widgets['final_suffix_entry'].grid(row=new_grid_row, column=7, sticky=tk.EW, **pad_options)
# --- Update Remove Button Command ---
remove_button = widgets['remove_button']
remove_button.grid(row=new_grid_row, column=8, sticky=tk.W, pady=1, padx=(5, 15))
remove_button.configure(command=lambda current_idx=idx: self._handle_remove_mapping_click(current_idx))
def _schedule_config_save(self, *args, delay=1500):
"""Debounces calls to the save configuration callback."""
if not self._initialized or not self._save_config_callback:
return # Don't save during init or if no callback provided
if self._save_timer_id is not None:
self.after_cancel(self._save_timer_id)
# print(f"DEBUG: Scheduling save in {delay}ms") # Debug
self._save_timer_id = self.after(delay, self._save_config_callback)
def get_mappings(self, include_incomplete=False):
"""Get all current mappings, optionally filtering incomplete ones."""
mappings = []
for row_data in self.mapping_rows:
try:
mapping = {
'svg_type': row_data['vars']['svg_type'].get().strip(),
'label_prefix': row_data['vars']['label_prefix'].get().strip(),
'element_type': row_data['vars']['element_type'].get().strip(),
'props_path': row_data['vars']['props_path'].get().strip(),
'width': row_data['vars']['width'].get().strip(),
'height': row_data['vars']['height'].get().strip(),
'x_offset': row_data['vars']['x_offset'].get().strip(),
'y_offset': row_data['vars']['y_offset'].get().strip(),
'final_prefix': row_data['vars']['final_prefix'].get().strip(),
'final_suffix': row_data['vars']['final_suffix'].get().strip()
}
# Basic Validation: Only include if essential info exists, unless forced
if include_incomplete or (mapping['svg_type'] and mapping['element_type']):
# Convert numeric fields safely
for key in ['width', 'height', 'x_offset', 'y_offset']:
# Try converting to int, keep as string if error or empty
try:
if mapping[key]: mapping[key] = int(mapping[key])
# else: keep empty string '' or handle as 0? Decide based on need.
# For saving, empty might mean use default. Keeping string allows flexibility.
except ValueError: pass # Keep original string if not valid int
mappings.append(mapping)
except (KeyError, tk.TclError) as e:
print(f"Warning: Skipping row during get_mappings due to error: {e}")
continue
return mappings
def load_mappings(self, mappings_data):
"""Load mappings into the frame, replacing existing ones."""
# --- Clear Existing Rows ---
# Cancel pending saves first
if self._save_timer_id is not None:
self.after_cancel(self._save_timer_id)
self._save_timer_id = None
# Iterate backwards through the list to remove rows
for i in range(len(self.mapping_rows) - 1, -1, -1):
self._remove_mapping_row(i)
# At this point, self.mapping_rows should be empty,
# and _remove_mapping_row should have added back one default row if it became empty.
# If the default row add logic is robust, we might not need to explicitly clear again.
# Let's ensure it's empty before loading.
if self.mapping_rows:
print("Warning: Rows still exist after clearing loop in load_mappings.")
# Force clear again?
for i in range(len(self.mapping_rows) - 1, -1, -1):
self._remove_mapping_row(i)
# --- Load New Mappings ---
self._initialized = False # Disable saving during load
if not mappings_data: # If input is empty list
mappings_to_load = self._default_mappings
else:
mappings_to_load = mappings_data
for mapping in mappings_to_load:
if isinstance(mapping, dict): # Basic check
self._add_mapping_row(data=mapping)
# --- Finalize ---
# Ensure at least one row exists (should be handled by _remove_mapping_row)
if not self.mapping_rows:
print("Warning: No rows after loading, adding default.")
self._add_mapping_row(DEFAULT_MAPPING)
self._initialized = True # Enable saving now
self.update_idletasks() # Update layout
self._on_frame_configure() # Recalculate scroll region
# print(f"Loaded {len(self.mapping_rows)} mappings.")
def cleanup_empty_rows(self):
"""Removes rows where both SVG Type and Element Type are empty, except if it's the only row."""
indices_to_remove = []
for i in range(len(self.mapping_rows) - 1, -1, -1):
# Don't remove the *only* row, even if empty
if len(self.mapping_rows) <= 1:
break
row_data = self.mapping_rows[i]
try:
svg_type = row_data['vars']['svg_type'].get().strip()
element_type = row_data['vars']['element_type'].get().strip()
if not svg_type and not element_type:
indices_to_remove.append(i)
except (KeyError, tk.TclError):
indices_to_remove.append(i) # Remove if vars/widgets are broken
if indices_to_remove:
print(f"Cleaning up {len(indices_to_remove)} empty/incomplete mapping rows.")
# Cancel any pending save before modifying rows
if self._save_timer_id is not None:
self.after_cancel(self._save_timer_id)
self._save_timer_id = None
for index in indices_to_remove:
self._remove_mapping_row(index) # This handles re-gridding
# Schedule a save after cleanup is done
self._schedule_config_save(delay=100)
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("Element Mapping Frame Test")
root.geometry("950x400")
# --- Style ---
style = ttk.Style()
style.theme_use('clam')
# Add a danger style for the remove button
style.configure("Danger.TButton", foreground='white', background='#a83232')
style.map("Danger.TButton", background=[('active', '#c44'), ('pressed', '#d55')])
# --- Save Callback ---
def mock_save_config():
print(f"[{time.strftime('%H:%M:%S')}] MOCK SAVE triggered!")
current_mappings = mapping_frame.get_mappings()
print(f"Current Mappings ({len(current_mappings)}):")
import json
print(json.dumps(current_mappings, indent=2))
# --- Frame Creation ---
mapping_frame = ElementMappingFrame(root, save_config_callback=mock_save_config)
mapping_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# --- Test Data ---
test_mappings = [
{'svg_type': 'rect', 'label_prefix': 'EQ', 'element_type': 'ia.display.view', 'props_path': 'Symbols/Valves/ValveSO', 'width': '20', 'height': '20', 'x_offset': '0', 'y_offset': '0', 'final_prefix': 'valves/', 'final_suffix': ''},
{'svg_type': 'circle', 'label_prefix': 'PMP', 'element_type': 'ia.display.view', 'props_path': 'Symbols/Pumps/PumpBasic', 'width': '30', 'height': '30', 'x_offset': '-15', 'y_offset': '-15', 'final_prefix': 'pumps/', 'final_suffix': '_status'},
{'svg_type': 'path', 'label_prefix': '', 'element_type': 'ia.display.label', 'props_path': '', 'width': '50', 'height': '15', 'x_offset': '0', 'y_offset': '0', 'final_prefix': 'labels/', 'final_suffix': ''},
# Add more diverse examples if needed
]
# --- Test Controls ---
controls_frame = ttk.Frame(root, padding=5)
controls_frame.pack()
load_button = ttk.Button(controls_frame, text="Load Test Data",
command=lambda: mapping_frame.load_mappings(test_mappings))
load_button.pack(side=tk.LEFT, padx=5)
clear_button = ttk.Button(controls_frame, text="Load Empty ([])",
command=lambda: mapping_frame.load_mappings([]))
clear_button.pack(side=tk.LEFT, padx=5)
cleanup_button = ttk.Button(controls_frame, text="Cleanup Empty Rows",
command=mapping_frame.cleanup_empty_rows)
cleanup_button.pack(side=tk.LEFT, padx=5)
get_button = ttk.Button(controls_frame, text="Get Mappings (Print)", command=mock_save_config)
get_button.pack(side=tk.LEFT, padx=5)
# --- Initial Load ---
# mapping_frame.load_mappings([]) # Start empty
mapping_frame.load_mappings(test_mappings) # Start with test data
root.mainloop()