SXXXXXXX_RadarDataReader/radar_data_reader/gui/profile_editor_window.py
2025-06-18 15:04:13 +02:00

290 lines
13 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
from ..core import data_structures as ds
from ..core.export_profiles import ExportProfile, ExportField
from ..utils import logger
log = logger.get_logger(__name__)
# --- Constants ---
# Base classes from which to start introspection.
# We add DataBatch to make fields like 'batch_id' available at the top level.
ROOT_DATA_CLASSES = {
"DataBatch": ds.DataBatch,
}
# Primitive types that should be treated as leaves in the tree
LEAF_TYPES = {int, float, str, bool}
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
# Work on a deep copy to easily detect unsaved changes
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) # Keep this window on top of the main one
self.grab_set() # Modal behavior
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)
# --- Left Frame: Profile Management ---
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)
# --- Middle Frame: Available Fields ---
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')
# --- Right Frame: Selected Fields and Actions ---
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: Save/Cancel ---
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 data structures and populates the Treeview."""
for name, class_obj in ROOT_DATA_CLASSES.items():
# Special handling for DataBatch to show its synthetic 'batch_id'
if name == "DataBatch":
self.fields_tree.insert("", "end", iid="batch_id", text="batch_id", values=("batch_id",))
# Now inspect its real fields, starting with an empty path
self._recursive_populate_tree(class_obj, "", "")
def _recursive_populate_tree(self, class_obj: Type, parent_id: str, current_path: str):
"""Recursively explores dataclasses to build the tree."""
if not dataclasses.is_dataclass(class_obj):
return
for field in dataclasses.fields(class_obj):
field_name = field.name
field_type = field.type
# --- THIS IS THE CORRECTED LINE ---
new_path = f"{current_path}.{field_name}" if current_path else field_name
node_id = new_path
# Check if field type is another dataclass (a branch)
if dataclasses.is_dataclass(field_type):
child_node = self.fields_tree.insert(parent_id, "end", iid=node_id, text=field_name)
self._recursive_populate_tree(field_type, child_node, new_path)
# Check if it's a primitive type (a leaf)
elif field_type in LEAF_TYPES:
self.fields_tree.insert(parent_id, "end", iid=node_id, text=field_name, values=(new_path,))
# Handle List[dataclass] or other complex types if needed in future
else:
self.fields_tree.insert(parent_id, "end", iid=node_id, text=f"{field_name} [{field_type.__name__}]")
def _load_profiles_to_combobox(self):
"""Updates the profile combobox with current profile names."""
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) -> ExportProfile | None:
"""Finds the profile object matching the current combobox selection."""
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):
"""Handles changing the profile selection."""
# Here you could add logic to check for unsaved changes before switching
self._load_profile_into_ui()
def _load_profile_into_ui(self):
"""Loads the fields of the currently selected profile into the listbox."""
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):
"""Adds the selected field from the tree to the current profile."""
selected_item_id = self.fields_tree.focus()
if not selected_item_id:
return
# We only add leaf nodes which have a data path
data_path = self.fields_tree.item(selected_item_id, "values")
if not data_path:
messagebox.showinfo("Cannot Add Field", "Please select a specific data field (a leaf node), not a category.", parent=self)
return
data_path = data_path[0]
column_name = selected_item_id.split('.')[-1]
profile = self._get_current_profile()
if not profile: return
# Avoid duplicates
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):
"""Removes the selected field from the current profile."""
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):
"""Moves the selected field up or down in the list."""
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):
"""Creates a new, empty profile."""
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):
"""Deletes the currently selected profile."""
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:
"""Checks if any profiles have been modified since last save."""
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):
"""Saves all profiles and closes the window."""
log.info("Saving export profiles and closing editor.")
self.controller.save_export_profiles(self.profiles)
self.destroy()
def _on_close(self):
"""Handles the window close event."""
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: # Yes
self._on_save_and_close()
elif response is False: # No
self.destroy()
else: # Cancel
return
else:
self.destroy()