# radar_data_reader/gui/profile_editor_window.py import tkinter as tk from tkinter import ttk, simpledialog, messagebox import ctypes import copy import re from typing import List, Type, Dict, Any, Optional from .gui_utils import center_window from ..core import data_structures as ds from ..core.data_enums import ENUM_REGISTRY from ..core.export_profiles import ExportProfile, ExportField from ..utils import logger log = logger.get_logger(__name__) class EditPathDialog(tk.Toplevel): def __init__(self, parent, initial_value=""): super().__init__(parent) self.transient(parent) self.grab_set() self.title("Edit Data Path") self.withdraw() self.result = None main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) main_frame.columnconfigure(0, weight=1) instructions = "Enter the full data path. Use '.' for attributes and '[index]' for arrays.\nExample 1: main_header.ge_header.mode.master_mode\nExample 2: timer_data.blob.payload.aesa_delay[0].fifo[3]" ttk.Label(main_frame, text=instructions, justify=tk.LEFT).grid( row=0, column=0, sticky="w", pady=(0, 10) ) self.path_var = tk.StringVar(value=initial_value) self.path_entry = ttk.Entry( main_frame, textvariable=self.path_var, font=("Courier", 10), width=90 ) self.path_entry.grid(row=1, column=0, sticky="ew") self.path_entry.focus_set() self.path_entry.selection_range(0, tk.END) button_frame = ttk.Frame(main_frame) button_frame.grid(row=2, column=0, sticky="e", pady=(10, 0)) ttk.Button( button_frame, text="OK", command=self._on_ok, default=tk.ACTIVE ).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="Cancel", command=self._on_cancel).pack( side=tk.LEFT ) self.protocol("WM_DELETE_WINDOW", self._on_cancel) self.bind("", self._on_ok) self.bind("", self._on_cancel) center_window(self, parent) def _on_ok(self, event=None): self.result = self.path_var.get().strip() self.destroy() def _on_cancel(self, event=None): self.result = None self.destroy() def wait_for_input(self): self.wait_window() return self.result class ProfileEditorWindow(tk.Toplevel): 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.withdraw() self._init_window() self._init_vars() self._create_widgets() self._populate_available_fields_tree() self._load_profiles_to_combobox() center_window(self, self.master) self.protocol("WM_DELETE_WINDOW", self._on_close) def _init_window(self): self.title("Export Profile Editor") self.geometry("1200x700") self.transient(self.master) def _init_vars(self): self.selected_profile_name = tk.StringVar() def _create_widgets(self): 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=2) 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("<>", 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), 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=3) 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=5) 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=10) ttk.Button( action_btn_frame, text="Down", command=lambda: self._move_field(1) ).grid(pady=5) ttk.Button( action_btn_frame, text="Edit Path", command=self._edit_selected_field_path ).grid(pady=10) ttk.Button( action_btn_frame, text="Reset", command=self._clear_selected_fields ).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_tree = ttk.Treeview( selected_fields_frame, columns=("display_name", "data_path", "translate"), show="headings", selectmode="browse", ) self.selected_tree.heading("display_name", text="Field Name") self.selected_tree.heading("data_path", text="Source Path") self.selected_tree.heading("translate", text="Translate") self.selected_tree.column("display_name", width=150, stretch=True) self.selected_tree.column("data_path", width=300, stretch=True) self.selected_tree.column("translate", width=80, anchor="center", stretch=False) self.selected_tree.grid(row=0, column=0, sticky="nsew") self.selected_tree.bind("", self._on_selected_tree_click) self.selected_tree.bind("", self._on_selected_tree_double_click) 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 _on_selected_tree_double_click(self, event): if self.selected_tree.identify_row(event.y): self._edit_selected_field_path() def _edit_selected_field_path(self): selection = self.selected_tree.selection() if not selection: messagebox.showinfo( "No Selection", "Please select a field to edit.", parent=self ) return index = int(selection[0]) profile = self._get_current_profile() if not profile: return field = profile.fields[index] dialog = EditPathDialog(self, initial_value=field.data_path) new_path = dialog.wait_for_input() if new_path is not None and new_path != field.data_path: if not re.match(r"^[\w\.\[\]]+$", new_path): messagebox.showerror( "Invalid Path", "The path contains invalid characters.", parent=self ) return field.data_path = new_path self._load_profile_into_ui() def _on_selected_tree_click(self, event): region = self.selected_tree.identify_region(event.x, event.y) if region != "cell": return column_id = self.selected_tree.identify_column(event.x) if column_id != "#3": return item_id = self.selected_tree.identify_row(event.y) if not item_id: return profile = self._get_current_profile() if not profile: return field_index = int(item_id) field = profile.fields[field_index] base_path = re.sub(r"\[\d+\]", "", field.data_path) if base_path in ENUM_REGISTRY: field.translate_with_enum = not field.translate_with_enum self._load_profile_into_ui() def _load_profile_into_ui(self): for i in self.selected_tree.get_children(): self.selected_tree.delete(i) profile = self._get_current_profile() if not profile: return for index, field in enumerate(profile.fields): base_path = re.sub(r"\[\d+\]", "", field.data_path) is_translatable = base_path in ENUM_REGISTRY checkbox_char = ( "☑" if is_translatable and field.translate_with_enum else "☐" ) self.selected_tree.insert( "", "end", iid=str(index), values=(field.column_name, field.data_path, checkbox_char), ) def _clear_selected_fields(self): profile = self._get_current_profile() if not profile or not profile.fields: return if messagebox.askyesno( "Confirm Clear", f"Are you sure you want to remove all fields from the profile '{profile.name}'?", parent=self, ): profile.fields.clear() self._load_profile_into_ui() def _populate_available_fields_tree(self): self.fields_tree.delete(*self.fields_tree.get_children()) batch_root = self.fields_tree.insert( "", "end", iid="batch_properties", text="Batch Properties" ) self.fields_tree.insert( batch_root, "end", iid="batch_id", text="batch_id", values=("batch_id", "batch_id"), ) header_root = self.fields_tree.insert( "", "end", iid="header_data", text="Header Data (DSPHDRIN)" ) self._recursive_populate_tree_ctypes( ds.GeHeader, header_root, "main_header.ge_header" ) cdpsts_root = self.fields_tree.insert( "", "end", iid="cdpsts_data", text="CDP/STS Block Data" ) self._recursive_populate_tree_ctypes( ds.CdpDataLayout, cdpsts_root, "cdp_sts_results.payload.data" ) timer_root = self.fields_tree.insert( "", "end", iid="timer_data", text="Timer Block Data" ) self._recursive_populate_tree_ctypes( ds.GrifoTimerBlob, timer_root, "timer_data.blob" ) aesa_root = self.fields_tree.insert( "", "end", iid="aesa_data", text="AESA Block Data" ) aesa_tx_root = self.fields_tree.insert( aesa_root, "end", iid="aesa_tx", text="TX (Command)" ) self._recursive_populate_tree_ctypes( ds.AntennaCmdBuffer, aesa_tx_root, "aesa_data.payload" ) aesa_rx_root = self.fields_tree.insert( aesa_root, "end", iid="aesa_rx", text="RX (Reply)" ) self._recursive_populate_tree_ctypes( ds.AntennaReplyBuffer, aesa_rx_root, "aesa_data.payload" ) def _recursive_populate_tree_ctypes( self, class_obj: Type[ctypes.Structure], parent_id: str, base_path: str ): if not hasattr(class_obj, "_fields_"): return for field_name, field_type in class_obj._fields_: current_path = f"{base_path}.{field_name}" node_id = f"{parent_id}_{field_name}" if hasattr(field_type, "_fields_"): child_node = self.fields_tree.insert( parent_id, "end", iid=node_id, text=field_name ) self._recursive_populate_tree_ctypes( field_type, child_node, current_path ) elif hasattr(field_type, "_length_"): self.fields_tree.insert( parent_id, "end", iid=node_id, text=f"{field_name} [Array]", values=(field_name, current_path), ) else: display_text = f"{field_name}" if current_path in ENUM_REGISTRY: display_text += " (Enum)" 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 _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.", 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", "This field 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_tree.selection() if not selection: return index_to_remove = int(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_tree.selection() if not selection: return index = int(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_tree.selection_set(str(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?", parent=self, ) if response is True: self._on_save_and_close() elif response is False: self.destroy() else: self.destroy()