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("", 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("", self._on_mousewheel, add='+') widget.bind("", self._on_mousewheel, add='+') # Windows widget.bind("", 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()