SXXXXXXX_RadarDataReader/radar_data_reader/gui/profile_editor_window.py
2025-06-20 11:24:29 +02:00

273 lines
12 KiB
Python

"""
GUI Window for creating, editing, and deleting export profiles.
"""
import tkinter as tk
from tkinter import ttk, simpledialog, messagebox
import dataclasses
import inspect
import copy
from typing import List, Type, Dict, Any, Union, Optional
from ..core import data_structures as ds
from ..core.export_profiles import ExportProfile, ExportField
from ..utils import logger
log = logger.get_logger(__name__)
# Primitive types that should be treated as leaves in the tree
LEAF_TYPES = {int, float, str, bool, list}
class ProfileEditorWindow(tk.Toplevel):
"""A Toplevel window for managing export profiles."""
def __init__(self, master, controller, profiles: List[ExportProfile]):
super().__init__(master)
self.master = master
self.controller = controller
self.profiles = copy.deepcopy(profiles)
self._original_profiles_dict = {p.name: p.to_dict() for p in self.profiles}
self._init_window()
self._init_vars()
self._create_widgets()
self._populate_available_fields_tree()
self._load_profiles_to_combobox()
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_window(self):
"""Initializes window properties."""
self.title("Export Profile Editor")
self.geometry("900x600")
self.transient(self.master)
self.grab_set()
def _init_vars(self):
"""Initializes Tkinter variables."""
self.selected_profile_name = tk.StringVar()
self.selected_fields_var = tk.Variable(value=[])
def _create_widgets(self):
"""Creates the main layout and widgets for the editor."""
main_pane = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
main_pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
profile_mgmt_frame = ttk.LabelFrame(main_pane, text="Profiles")
main_pane.add(profile_mgmt_frame, weight=1)
profile_mgmt_frame.columnconfigure(0, weight=1)
cb_frame = ttk.Frame(profile_mgmt_frame)
cb_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
cb_frame.columnconfigure(0, weight=1)
self.profile_combobox = ttk.Combobox(cb_frame, textvariable=self.selected_profile_name, state="readonly")
self.profile_combobox.grid(row=0, column=0, sticky="ew")
self.profile_combobox.bind("<<ComboboxSelected>>", self._on_profile_selected)
btn_frame = ttk.Frame(profile_mgmt_frame)
btn_frame.grid(row=1, column=0, sticky="ew", padx=5)
btn_frame.columnconfigure((0, 1, 2), weight=1)
ttk.Button(btn_frame, text="New", command=self._on_new_profile).grid(row=0, column=0, sticky="ew", padx=2)
ttk.Button(btn_frame, text="Delete", command=self._on_delete_profile).grid(row=0, column=1, sticky="ew", padx=2)
fields_frame = ttk.LabelFrame(main_pane, text="Available Fields")
main_pane.add(fields_frame, weight=2)
fields_frame.rowconfigure(0, weight=1)
fields_frame.columnconfigure(0, weight=1)
self.fields_tree = ttk.Treeview(fields_frame, selectmode="browse")
self.fields_tree.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
ysb = ttk.Scrollbar(fields_frame, orient='vertical', command=self.fields_tree.yview)
self.fields_tree.configure(yscrollcommand=ysb.set)
ysb.grid(row=0, column=1, sticky='ns')
selected_frame_container = ttk.Frame(main_pane)
main_pane.add(selected_frame_container, weight=2)
selected_frame_container.rowconfigure(0, weight=1)
selected_frame_container.columnconfigure(1, weight=1)
action_btn_frame = ttk.Frame(selected_frame_container)
action_btn_frame.grid(row=0, column=0, sticky="ns", padx=5, pady=5)
ttk.Button(action_btn_frame, text=">>", command=self._add_field).grid(pady=5)
ttk.Button(action_btn_frame, text="<<", command=self._remove_field).grid(pady=5)
ttk.Button(action_btn_frame, text="Up", command=lambda: self._move_field(-1)).grid(pady=20)
ttk.Button(action_btn_frame, text="Down", command=lambda: self._move_field(1)).grid(pady=5)
selected_fields_frame = ttk.LabelFrame(selected_frame_container, text="Selected Fields for Profile")
selected_fields_frame.grid(row=0, column=1, sticky="nsew")
selected_fields_frame.rowconfigure(0, weight=1)
selected_fields_frame.columnconfigure(0, weight=1)
self.selected_listbox = tk.Listbox(selected_fields_frame, listvariable=self.selected_fields_var, selectmode=tk.SINGLE)
self.selected_listbox.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
ysb_list = ttk.Scrollbar(selected_fields_frame, orient='vertical', command=self.selected_listbox.yview)
self.selected_listbox.config(yscrollcommand=ysb_list.set)
ysb_list.grid(row=0, column=1, sticky='ns')
bottom_frame = ttk.Frame(self)
bottom_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
ttk.Button(bottom_frame, text="Save & Close", command=self._on_save_and_close).pack(side=tk.RIGHT)
ttk.Button(bottom_frame, text="Cancel", command=self._on_close).pack(side=tk.RIGHT, padx=5)
def _populate_available_fields_tree(self):
"""Introspects the data structures and populates the Treeview correctly."""
self.fields_tree.delete(*self.fields_tree.get_children())
# --- Root 1: Batch-level properties ---
batch_root = self.fields_tree.insert("", "end", iid="batch_properties", text="Batch Properties")
# Add batch_id directly as it's a top-level property
self.fields_tree.insert(batch_root, "end", iid="batch_id", text="batch_id", values=("batch_id", "batch_id"))
# --- Root 2: Header data properties ---
# The user is interested in the contents of the header block.
header_root = self.fields_tree.insert("", "end", iid="header_data", text="Header Data (from DspHeaderIn)")
# We start the recursion from GeHeader, with a base path pointing to it.
self._recursive_populate_tree(ds.GeHeader, header_root, "main_header.ge_header")
def _recursive_populate_tree(self, class_obj: Type, parent_id: str, base_path: str):
"""Recursively explores dataclasses to build the tree, starting from a base path."""
if not dataclasses.is_dataclass(class_obj):
return
for field in dataclasses.fields(class_obj):
field_name = field.name
current_path = f"{base_path}.{field_name}"
node_id = f"{parent_id}_{field_name}"
# Determine the type to inspect, handling Optional and List
type_to_inspect = field.type
if hasattr(type_to_inspect, '__origin__') and type_to_inspect.__origin__ in [Union, list]:
args = getattr(type_to_inspect, '__args__', [])
non_none_args = [arg for arg in args if arg is not type(None)]
if non_none_args:
type_to_inspect = non_none_args[0]
# If it's another dataclass, create a branch and recurse
if dataclasses.is_dataclass(type_to_inspect):
child_node = self.fields_tree.insert(parent_id, "end", iid=node_id, text=field_name)
self._recursive_populate_tree(type_to_inspect, node_id, current_path)
# If it's a simple type, create a leaf
else:
display_text = f"{field_name}"
if field.type in LEAF_TYPES:
display_text = f"{field_name} [{field.type.__name__}]"
self.fields_tree.insert(parent_id, "end", iid=node_id, text=display_text, values=(field_name, current_path))
def _load_profiles_to_combobox(self):
profile_names = [p.name for p in self.profiles]
self.profile_combobox['values'] = profile_names
if profile_names:
self.selected_profile_name.set(profile_names[0])
self._load_profile_into_ui()
def _get_current_profile(self) -> Optional[ExportProfile]:
name = self.selected_profile_name.get()
return next((p for p in self.profiles if p.name == name), None)
def _on_profile_selected(self, event=None):
self._load_profile_into_ui()
def _load_profile_into_ui(self):
profile = self._get_current_profile()
if not profile:
self.selected_fields_var.set([])
return
display_list = [f.column_name for f in profile.fields]
self.selected_fields_var.set(display_list)
def _add_field(self):
selected_item_id = self.fields_tree.focus()
if not selected_item_id:
return
item_values = self.fields_tree.item(selected_item_id, "values")
if not item_values or len(item_values) < 2:
messagebox.showinfo("Cannot Add Field", "Please select a specific data field (a leaf node), not a category.", parent=self)
return
column_name, data_path = item_values
profile = self._get_current_profile()
if not profile: return
if any(f.data_path == data_path for f in profile.fields):
messagebox.showinfo("Duplicate Field", f"The field '{column_name}' is already in the profile.", parent=self)
return
profile.fields.append(ExportField(column_name=column_name, data_path=data_path))
self._load_profile_into_ui()
def _remove_field(self):
selection = self.selected_listbox.curselection()
if not selection: return
index_to_remove = selection[0]
profile = self._get_current_profile()
if not profile: return
del profile.fields[index_to_remove]
self._load_profile_into_ui()
def _move_field(self, direction: int):
selection = self.selected_listbox.curselection()
if not selection: return
index = selection[0]
new_index = index + direction
profile = self._get_current_profile()
if not profile or not (0 <= new_index < len(profile.fields)):
return
fields = profile.fields
fields.insert(new_index, fields.pop(index))
self._load_profile_into_ui()
self.selected_listbox.selection_set(new_index)
def _on_new_profile(self):
name = simpledialog.askstring("New Profile", "Enter a name for the new profile:", parent=self)
if not name or not name.strip():
return
if any(p.name == name for p in self.profiles):
messagebox.showerror("Error", f"A profile with the name '{name}' already exists.", parent=self)
return
new_profile = ExportProfile(name=name.strip())
self.profiles.append(new_profile)
self._load_profiles_to_combobox()
self.selected_profile_name.set(name)
self._load_profile_into_ui()
def _on_delete_profile(self):
profile = self._get_current_profile()
if not profile: return
if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete the profile '{profile.name}'?", parent=self):
self.profiles.remove(profile)
self._load_profiles_to_combobox()
def _check_unsaved_changes(self) -> bool:
current_profiles_dict = {p.name: p.to_dict() for p in self.profiles}
return current_profiles_dict != self._original_profiles_dict
def _on_save_and_close(self):
log.info("Saving export profiles and closing editor.")
self.controller.save_export_profiles(self.profiles)
self.destroy()
def _on_close(self):
if self._check_unsaved_changes():
response = messagebox.askyesnocancel(
"Unsaved Changes",
"You have unsaved changes. Would you like to save them before closing?",
parent=self
)
if response is True:
self._on_save_and_close()
elif response is False:
self.destroy()
else:
return
else:
self.destroy()