import tkinter as tk from tkinter import ttk, scrolledtext import sys import json # Moved import to top # Assuming RedirectText is in utils.py from utils import RedirectText from gui.element_mapping_frame import ElementMappingFrame from gui.results_frame import ResultsFrame from gui.log_frame import LogFrame class NotebookManager(ttk.Notebook): """Manages the notebook tabs for the application.""" def __init__(self, app_instance, theme_manager, **kwargs): """ Initialize the Notebook Manager. Args: app_instance: The main SVGProcessorApp instance. theme_manager: The theme manager instance. **kwargs: Additional arguments for ttk.Notebook. """ super().__init__(app_instance.root, **kwargs) self.app = app_instance # Store reference to the main app self.theme_manager = theme_manager self._create_tabs() def _create_tabs(self): """Create the notebook tabs.""" # Element Mapping Tab self.element_mapping_frame = ElementMappingFrame(self, save_config_callback=self._trigger_save) self.add(self.element_mapping_frame, text='Element Mapping') # self.theme_manager.configure_widget(self.element_mapping_frame) # Results Tab self.results_frame = ResultsFrame(self, export_command=self.app.create_scada_view) self.add(self.results_frame, text='Results') # self.theme_manager.configure_widget(self.results_frame) # Logs Tab self.log_frame = LogFrame(self) self.add(self.log_frame, text='Logs') # self.theme_manager.configure_widget(self.log_frame) # Configure the notebook itself with the theme # self.theme_manager.configure_widget(self) def _trigger_save(self): """Placeholder or method to call the main app's save function if needed.""" # This might be called by ElementMappingFrame when changes occur. # Check if the main app has a save method and call it. if hasattr(self.app, '_save_config_from_ui'): # print("NotebookManager triggering config save...") # Debug # Schedule the save slightly later to avoid issues during trace callbacks self.app.root.after_idle(self.app._save_config_from_ui) else: print("Warning: Main application has no _save_config_from_ui method to call.") def get_element_mapping_frame(self): """Return the Element Mapping frame.""" return self.element_mapping_frame def get_results_frame(self): """Return the Results frame.""" return self.results_frame def get_log_frame(self): """Return the Log frame.""" return self.log_frame def select_tab(self, index): """Select a specific tab in the notebook by index.""" if self: try: self.select(index) except tk.TclError as e: print(f"Error selecting notebook tab {index}: {e}") def update_log(self, text): """Append text to the log display.""" if self.log_frame and self.log_frame.winfo_exists(): try: self.log_frame.log_message(str(text)) except tk.TclError as e: # print(f"Log update error: {e}") # Ignore if widget destroyed pass def update_results(self, elements): """Clear and display formatted JSON results in the results tab.""" if not self.results_frame or not self.results_frame.winfo_exists(): print("Warning: Results display widget not available.") return self.results_frame.set_results("") if not elements: self.results_frame.set_results("No elements found matching the criteria.") return try: # import json # Moved to top formatted_json = json.dumps(elements, indent=2) # Simple insertion for now, add chunking if needed later self.results_frame.set_results(formatted_json) except TypeError as te: error_msg = f"Result Display Error: Data could not be serialized to JSON.\n{te}\n\nData Preview:\n{str(elements)[:500]}..." print(error_msg) self.results_frame.set_results(error_msg) except Exception as e: error_msg = f"Unexpected error displaying results: {e}" print(error_msg) self.results_frame.set_results(error_msg) def get_results_text(self): """Get the current text content from the results tab.""" if self.results_frame and self.results_frame.winfo_exists(): return self.results_frame.get_results().strip() return "" def clear_results(self): """Clear the results text widget.""" if self.results_frame and self.results_frame.winfo_exists(): self.results_frame.set_results("") def start_progress(self, speed=10): """Start the indeterminate progress bar.""" if self.element_mapping_frame and self.element_mapping_frame.progress: self.element_mapping_frame.progress.start(speed) def stop_progress(self): """Stop the progress bar.""" if self.element_mapping_frame and self.element_mapping_frame.progress: self.element_mapping_frame.progress.stop() # ---------------------------------------------------------------------- # Element Mapping Tab Methods # ---------------------------------------------------------------------- def _create_element_mapping_tab(self, parent_frame): """Create the content for the Element Mapping tab, including a scrollable grid.""" # Main frame for the tab content mapping_content_frame = ttk.Frame(parent_frame) mapping_content_frame.pack(fill=tk.BOTH, expand=True) # --- Top Info/Controls --- top_frame = ttk.Frame(mapping_content_frame) top_frame.pack(fill=tk.X, pady=(5, 10)) ttk.Label(top_frame, text="Element Mapping Configuration", style="Header.TLabel").pack(side=tk.LEFT, anchor=tk.W) # --- Scrollable Table Frame --- table_container = ttk.Frame(mapping_content_frame) table_container.pack(fill=tk.BOTH, expand=True) canvas = tk.Canvas(table_container, borderwidth=0, highlightthickness=0) # No border for canvas scrollbar = ttk.Scrollbar(table_container, orient="vertical", command=canvas.yview) # Frame inside canvas holds the actual grid of widgets self.mapping_frame = ttk.Frame(canvas, padding=(5, 5)) # Padding inside grid frame canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") canvas_frame_id = canvas.create_window((0, 0), window=self.mapping_frame, anchor="nw") # Update scroll region when the inner frame's size changes def on_frame_configure(event=None): canvas.configure(scrollregion=canvas.bbox("all")) # Update the width of the inner frame when the canvas width changes def on_canvas_configure(event): canvas.itemconfig(canvas_frame_id, width=event.width) self.mapping_frame.bind("", on_frame_configure) canvas.bind('', on_canvas_configure) # Bind mouse wheel scrolling to the canvas (platform specific) self._bind_mousewheel(canvas) # --- Grid Headers (inside self.mapping_frame) --- headers = [ ("SVG Type", 10), ("Label Prefix", 10), ("Output Type", 20), ("Props Path", 30), ("Size (WxH)", 12), ("Offset (X,Y)", 12), ("Final Prefix", 15), ("Final Suffix", 15), ("Del", 3) ] for col, (text, _) in enumerate(headers): header_label = ttk.Label(self.mapping_frame, text=text, font=('Helvetica', 9, 'bold'), anchor=tk.W) header_label.grid(row=0, column=col, sticky=tk.EW, pady=(0, 5), padx=3) # Optional: Configure column weights here if needed if text == "Output Type": self.mapping_frame.columnconfigure(col, weight=1) if text == "Props Path": self.mapping_frame.columnconfigure(col, weight=2) # --- Add Button (outside scrollable area) --- self.add_button_frame = ttk.Frame(mapping_content_frame) self.add_button_frame.pack(fill=tk.X, pady=(10, 5)) # Below scrollable area ttk.Button(self.add_button_frame, text=" + Add Mapping ", command=self._handle_add_mapping_click).pack(side=tk.LEFT, padx=5) # Initialize mapping rows list here if not done in __init__ if not hasattr(self, 'mapping_rows'): self.mapping_rows = [] def _bind_mousewheel(self, widget): """Bind mouse wheel events for scrolling across platforms.""" if sys.platform == "linux": widget.bind("", lambda e: self._on_mousewheel(e, widget, -1), add='+') widget.bind("", lambda e: self._on_mousewheel(e, widget, 1), add='+') else: # Windows and macOS widget.bind("", lambda e: self._on_mousewheel(e, widget), add='+') def _on_mousewheel(self, event, canvas, direction=None): """Handle mouse wheel scroll events for the canvas.""" if direction: # Linux Button-4/5 delta = direction else: # Windows/macOS MouseWheel # Determine scroll direction and magnitude (platform dependent) if sys.platform == "darwin": # macOS may need adjustment delta = -1 * event.delta else: # Windows delta = -1 * (event.delta // 120) # Windows delta is typically +/- 120 canvas.yview_scroll(delta, "units") def _handle_add_mapping_click(self): """Callback for the 'Add New Mapping' button.""" original_allow_empty = self.element_mapping_frame.allow_empty_rows self.element_mapping_frame.allow_empty_rows = True new_index = self.add_mapping_row() # Add blank row self.element_mapping_frame.allow_empty_rows = original_allow_empty # Focus and scroll if 0 <= new_index < len(self.mapping_rows): try: self.mapping_rows[new_index]['svg_entry'].focus_set() except (KeyError, tk.TclError): pass self._scroll_mapping_to_bottom() def _scroll_mapping_to_bottom(self): """Scrolls the element mapping canvas to the bottom.""" self.theme_manager.update_scroll_region(self.element_mapping_frame.mapping_frame) def add_mapping_row(self, data=None): """Adds a mapping row to the UI, optionally populated with data.""" if data is None: data = {} row_index = len(self.mapping_rows) + 1 # Grid row below header # --- Data Variables --- 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', '')), } # Trigger config save on change save_callback = self.element_mapping_frame.mapping_callbacks.get('save_config', lambda *a: None) for var in row_vars.values(): var.trace_add("write", lambda *a, cb=save_callback: self._schedule_config_save(cb)) # --- Widgets --- entries = {} col = 0 pad_x = 3 pad_y = 1 entries['svg_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['svg_type'], width=10) entries['svg_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 entries['label_prefix_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['label_prefix'], width=10) entries['label_prefix_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 entries['element_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['element_type'], width=20) entries['element_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 entries['props_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['props_path'], width=30) entries['props_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 # Size Frame size_frame = ttk.Frame(self.mapping_frame) size_frame.grid(row=row_index, column=col, sticky=tk.EW, pady=0, padx=0); col+=1 entries['width_entry'] = ttk.Entry(size_frame, textvariable=row_vars['width'], width=4) entries['width_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(pad_x,1)) ttk.Label(size_frame, text="×").pack(side=tk.LEFT) entries['height_entry'] = ttk.Entry(size_frame, textvariable=row_vars['height'], width=4) entries['height_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(1,pad_x)) entries['size_frame'] = size_frame # Offset Frame offset_frame = ttk.Frame(self.mapping_frame) offset_frame.grid(row=row_index, column=col, sticky=tk.EW, pady=0, padx=0); col+=1 entries['x_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['x_offset'], width=4) entries['x_offset_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(pad_x,1)) ttk.Label(offset_frame, text=",").pack(side=tk.LEFT) entries['y_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['y_offset'], width=4) entries['y_offset_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(1,pad_x)) entries['offset_frame'] = offset_frame # Final Prefix/Suffix entries['final_prefix_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['final_prefix'], width=15) entries['final_prefix_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 entries['final_suffix_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['final_suffix'], width=15) entries['final_suffix_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 # Remove Button current_list_index = len(self.mapping_rows) remove_button = ttk.Button(self.mapping_frame, text="×", width=2, style="Danger.TButton", command=lambda idx=current_list_index: self._handle_remove_mapping_click(idx)) remove_button.grid(row=row_index, column=col, sticky=tk.W, pady=0, padx=pad_x); col+=1 entries['remove_button'] = remove_button # Store row data row_data_dict = {**row_vars, **entries, 'grid_row': row_index} self.mapping_rows.append(row_data_dict) # Schedule save if needed if self.element_mapping_frame.initialized_mappings: if self.element_mapping_frame.skip_next_save: self.element_mapping_frame.skip_next_save = False else: self._schedule_config_save(save_callback, delay=100) # Update canvas scroll region self._update_mapping_scrollregion() return current_list_index def _handle_remove_mapping_click(self, index_in_list): """Callback to remove a specific mapping row.""" self.remove_mapping_row(index_in_list) save_callback = self.element_mapping_frame.mapping_callbacks.get('save_config', lambda: None) self._schedule_config_save(save_callback, delay=100) # Save quickly after removal def remove_mapping_row(self, index_in_list): """Removes the mapping row widgets and data at the given list index.""" if not (0 <= index_in_list < len(self.mapping_rows)): return row_to_remove = self.mapping_rows[index_in_list] # Destroy Widgets widgets_to_destroy = [ 'svg_entry', 'label_prefix_entry', 'element_entry', 'props_entry', 'width_entry', 'height_entry', 'x_offset_entry', 'y_offset_entry', 'final_prefix_entry', 'final_suffix_entry', 'remove_button', 'size_frame', 'offset_frame' ] for key in widgets_to_destroy: widget = row_to_remove.get(key) if widget and isinstance(widget, tk.Widget): try: widget.destroy() except tk.TclError: pass # Remove data from list self.mapping_rows.pop(index_in_list) # Re-grid remaining rows and update commands self._reindex_mapping_rows() # Handle empty list case if not self.mapping_rows: self.element_mapping_frame.skip_next_save = True self.add_mapping_row() # Add default row # Update scroll region self._update_mapping_scrollregion() def _reindex_mapping_rows(self): """Update grid rows and remove button commands after list changes.""" for i, row_data in enumerate(self.mapping_rows): new_grid_row = i + 1 col = 0 pad_x = 3 pad_y = 1 # Grid widgets (ensure they exist) row_data.get('svg_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 row_data.get('label_prefix_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 row_data.get('element_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 row_data.get('props_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 row_data.get('size_frame', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=0, padx=0); col+=1 row_data.get('offset_frame', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=0, padx=0); col+=1 row_data.get('final_prefix_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 row_data.get('final_suffix_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1 remove_button = row_data.get('remove_button', None) if remove_button: remove_button.grid(row=new_grid_row, column=col, sticky=tk.W, pady=0, padx=pad_x); col+=1 # Update Remove Button Command if remove_button and isinstance(remove_button, ttk.Button): remove_button.configure(command=lambda idx=i: self._handle_remove_mapping_click(idx)) row_data['grid_row'] = new_grid_row def _update_mapping_scrollregion(self): """Forces an update of the mapping canvas scroll region.""" self.theme_manager.update_scroll_region(self.element_mapping_frame.mapping_frame) def _schedule_config_save(self, save_callback, delay=1500): """Debounces calls to the save configuration callback.""" if self._save_timer_id is not None: self.theme_manager.after_cancel(self._save_timer_id) # print(f"DEBUG: Scheduling save in {delay}ms") # Debug self._save_timer_id = self.theme_manager.after(delay, save_callback) def load_element_mappings(self, mappings_data): """Clears existing mapping rows and loads new ones from data.""" # Clear existing UI rows for i in range(len(self.mapping_rows) - 1, -1, -1): self.remove_mapping_row(i) # Load new mappings if isinstance(mappings_data, list) and mappings_data: for mapping_dict in mappings_data: if isinstance(mapping_dict, dict): self.add_mapping_row(data=mapping_dict) # Ensure at least one row exists if not self.mapping_rows: self.element_mapping_frame.skip_next_save = True self.add_mapping_row() # Add default blank row self.element_mapping_frame.initialized_mappings = True self._update_mapping_scrollregion() def get_element_mappings(self): """Extracts the current element mapping configuration from the UI.""" mappings_to_return = [] if not hasattr(self, 'mapping_rows'): return mappings_to_return for row_data in self.mapping_rows: try: svg_type = row_data['svg_type'].get().strip() element_type = row_data['element_type'].get().strip() # Only include valid rows (must have svg_type and element_type) if not svg_type or not element_type: continue mapping = { 'svg_type': svg_type, 'element_type': element_type, 'label_prefix': row_data['label_prefix'].get().strip(), 'props_path': row_data['props_path'].get().strip(), 'final_prefix': row_data['final_prefix'].get().strip(), 'final_suffix': row_data['final_suffix'].get().strip(), } # Convert numeric, handle errors gracefully (defaulting) for key, default in [('width', 14), ('height', 14), ('x_offset', 0), ('y_offset', 0)]: val_str = row_data[key].get().strip() try: mapping[key] = int(val_str) if val_str else default except ValueError: mapping[key] = default mappings_to_return.append(mapping) except (KeyError, AttributeError, tk.TclError) as e: print(f"Warning: Skipping invalid mapping row during get: {e}") continue return mappings_to_return def cleanup_incomplete_mappings(self): """Removes mapping rows where essential fields are empty.""" if not hasattr(self, 'mapping_rows'): return indices_to_remove = [] for i in range(len(self.mapping_rows) - 1, -1, -1): row_data = self.mapping_rows[i] try: svg_type = row_data.get('svg_type', tk.StringVar()).get().strip() element_type = row_data.get('element_type', tk.StringVar()).get().strip() # Remove if essential field is empty, unless it's the only row left if (not svg_type or not element_type) and len(self.mapping_rows) > 1: indices_to_remove.append(i) except (AttributeError, KeyError, tk.TclError): indices_to_remove.append(i) # Assume broken row if indices_to_remove: # Disable saving during cleanup if self._save_timer_id: self.theme_manager.after_cancel(self._save_timer_id) self._save_timer_id = None # Remove rows for index in indices_to_remove: self.remove_mapping_row(index) # Example Usage if __name__ == '__main__': root = tk.Tk() root.title("Notebook Manager Test") root.geometry("800x600") # Mock ThemeManager for testing class MockThemeManager: def configure_widget(self, widget): # Apply some basic styling for visibility try: widget.config(style='TFrame' if isinstance(widget, ttk.Frame) else 'TNotebook') except tk.TclError: pass # Ignore if style doesn't apply def toggle_theme(self): pass def apply_theme(self): pass def update_scroll_region(self, widget): pass def after_cancel(self, timer_id): pass def after(self, delay, callback): return 1 # Mock implementation style = ttk.Style() style.theme_use('clam') style.configure('TNotebook', background='#f0f0f0', tabmargins=[2, 5, 2, 0]) style.configure('TNotebook.Tab', padding=[5, 2], font=('Arial', 10)) style.configure('TFrame', background='#ffffff') mock_theme_manager = MockThemeManager() notebook_manager = NotebookManager(root, mock_theme_manager) notebook_manager.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # You can access the frames like this: # notebook_manager.get_results_frame().set_results("Sample results text") # notebook_manager.get_log_frame().log_message("Sample log message") # mappings = notebook_manager.get_element_mapping_frame().get_mappings() root.mainloop()