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

520 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, 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("<Configure>", on_frame_configure)
canvas.bind('<Configure>', 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("<Button-4>", lambda e: self._on_mousewheel(e, widget, -1), add='+')
widget.bind("<Button-5>", lambda e: self._on_mousewheel(e, widget, 1), add='+')
else: # Windows and macOS
widget.bind("<MouseWheel>", 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()