# radar_data_reader/gui/profile_editor_window.py import tkinter as tk from tkinter import ttk, simpledialog, messagebox import ctypes import copy import re import inspect 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]\nExample 3: d1553_data.latitude_deg (for calculated properties)" 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() # Initial population of the tree 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) # --- New Style Tag for used fields --- self.fields_tree.tag_configure( "used_field", foreground="gray", font=("", 0, "italic") ) 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", ) # --- New Style Tags for translatable cells --- self.selected_tree.tag_configure("translatable", foreground="#007acc") self.selected_tree.tag_configure("not_translatable", foreground="black") 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": # Only act on the "Translate" column 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() # Redraw to update checkbox def _load_profile_into_ui(self): # 1. Update the "Selected Fields" tree for i in self.selected_tree.get_children(): self.selected_tree.delete(i) profile = self._get_current_profile() if not profile: self._update_available_fields_tree_tags() # Still update tags for empty 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 field.translate_with_enum else "☐" tag = "translatable" if is_translatable else "not_translatable" self.selected_tree.insert( "", "end", iid=str(index), values=(field.column_name, field.data_path, checkbox_char), tags=(tag,), ) # 2. Update the "Available Fields" tree to show used fields self._update_available_fields_tree_tags() 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()) # --- Helper to create a non-selectable root node --- def add_virtual_root(iid, text): return self.fields_tree.insert("", "end", iid=iid, text=text, open=False) # --- Batch Properties --- batch_root = add_virtual_root("batch_properties", "Batch Properties") self._recursive_populate_properties( ds.DataBatch, batch_root, "", [ "blocks", "cdp_sts_results", "timer_data", "aesa_data", "d1553_data", "exp_data", "pc_data", "det_data", "main_header", ], ) # --- DSPHDRIN Data --- header_root = add_virtual_root("header_data", "Header Data (from DSPHDRIN)") self._recursive_populate_tree_ctypes( ds.GeHeader, header_root, "main_header.ge_header" ) # --- CDPSTS Data --- cdpsts_root = add_virtual_root("cdpsts_data", "CDP/STS Block Data") self._recursive_populate_tree_ctypes( ds.CdpStsPayload, cdpsts_root, "cdp_sts_results.payload" ) # --- D1553 Data (with visual grouping) --- d1553_root = add_virtual_root("d1553_data", "D1553 Block Data") d1553_nav_root = self.fields_tree.insert( d1553_root, "end", iid="d1553_nav", text="Navigation Data" ) d1553_status_root = self.fields_tree.insert( d1553_root, "end", iid="d1553_status", text="Status Data" ) nav_props = [ "mission_timestamp_str", "latitude_deg", "longitude_deg", "baro_altitude_m", "true_heading_deg", "magnetic_heading_deg", "platform_azimuth_deg", "north_velocity_ms", "east_velocity_ms", "down_velocity_ms", "ground_track_angle_deg", ] status_props = ["range_scale_nm", "raw_range_scale", "raw_mode"] self._recursive_populate_properties( ds.D1553Block, d1553_nav_root, "d1553_data", only_props=nav_props ) self._recursive_populate_properties( ds.D1553Block, d1553_status_root, "d1553_data", only_props=status_props ) # --- TIMER Data --- timer_root = add_virtual_root("timer_data", "Timer Block Data") self._recursive_populate_tree_ctypes( ds.GrifoTimerBlob, timer_root, "timer_data.blob" ) # ... (add other blocks like AESA, EXP, etc. as needed, following this pattern) ... def _update_available_fields_tree_tags(self): profile = self._get_current_profile() used_paths = {f.data_path for f in profile.fields} if profile else set() for item_id in self._get_all_tree_children(self.fields_tree, ""): item_values = self.fields_tree.item(item_id, "values") if item_values and len(item_values) >= 2: data_path = item_values[1] if data_path in used_paths: self.fields_tree.item(item_id, tags=("used_field",)) else: self.fields_tree.item(item_id, tags=()) def _get_all_tree_children(self, tree, item): children = tree.get_children(item) for child in children: yield child yield from self._get_all_tree_children(tree, child) 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_"): # It's a nested structure child_node = self.fields_tree.insert( parent_id, "end", iid=node_id, text=field_name, open=False ) self._recursive_populate_tree_ctypes( field_type, child_node, current_path ) elif hasattr(field_type, "_length_"): # It's an array self.fields_tree.insert( parent_id, "end", iid=node_id, text=f"{field_name} [Array]", values=(field_name, current_path), ) else: # It's a simple field 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 _recursive_populate_properties( self, class_obj: Type[Any], parent_id: str, base_path: str, excluded_props: List[str] = [], only_props: Optional[List[str]] = None, ): for name in dir(class_obj): if name.startswith("_") or name in excluded_props: continue if only_props and name not in only_props: continue if isinstance(getattr(class_obj, name, None), property): display_text = name current_path = f"{base_path}.{name}" if current_path in ENUM_REGISTRY: display_text += " (Enum)" self.fields_tree.insert( parent_id, "end", iid=f"{parent_id}_{name}", text=display_text, values=(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]) else: self.selected_profile_name.set("") 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, not a category.", parent=self, ) return tags = self.fields_tree.item(selected_item_id, "tags") if "used_field" in tags: messagebox.showinfo( "Duplicate Field", "This field is already in the current profile.", parent=self, ) return column_name, data_path = item_values profile = self._get_current_profile() if not profile: 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()