SXXXXXXX_RadarDataReader/radar_data_reader/gui/profile_editor_window.py
2025-06-25 13:36:14 +02:00

527 lines
22 KiB
Python

# 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 # Import necessary for inspecting properties
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("<Return>", self._on_ok)
self.bind("<Escape>", 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("<<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), 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("<Button-1>", self._on_selected_tree_click)
self.selected_tree.bind("<Double-1>", 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 Properties (standalone fields) ---
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"),
)
# --- DSPHDRIN Data ---
header_root = self.fields_tree.insert(
"", "end", iid="header_data", text="Header Data (from DSPHDRIN)"
)
self._recursive_populate_tree_ctypes(ds.GeHeader, header_root, "main_header.ge_header")
# --- CDPSTS Data ---
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 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 Data ---
aesa_root = self.fields_tree.insert(
"", "end", iid="aesa_data", text="AESA Block Data"
)
aesa_raw_data_root = self.fields_tree.insert(aesa_root, "end", iid="aesa_raw_data", text="Raw Data By Subtype")
self.fields_tree.insert(aesa_raw_data_root, "end", iid="aesa_raw_bytes", text="raw_data_bytes", values=("raw_data_bytes", "aesa_data.raw_data_bytes"))
aesa_synth_root = self.fields_tree.insert(aesa_root, "end", iid="aesa_synth", text="Synthetic Report (256 bytes)")
self._recursive_populate_tree_ctypes(ds.AesaSyntheticReport, aesa_synth_root, "aesa_data.payload")
# --- D1553 Data ---
d1553_root = self.fields_tree.insert(
"", "end", iid="d1553_data", text="D1553 Block Data"
)
d1553_raw_root = self.fields_tree.insert(d1553_root, "end", iid="d1553_payload", text="Raw Payload (D1553Payload)")
self._recursive_populate_tree_ctypes(ds.D1553Payload, d1553_raw_root, "d1553_data.payload")
d1553_calc_root = self.fields_tree.insert(d1553_root, "end", iid="d1553_calculated", text="Calculated Properties")
dummy_d1553_block = ds.D1553Block(block_name="D1553", block_size_words=0, is_valid=False) # Needs a valid (but dummy) instance
self._recursive_populate_properties(type(dummy_d1553_block), d1553_calc_root, "d1553_data")
# --- EXPANDER Data ---
exp_root = self.fields_tree.insert(
"", "end", iid="exp_data", text="Expander Block Data"
)
# Add the raw_data_bytes field for Expander
self.fields_tree.insert(exp_root, "end", iid="exp_raw_bytes", text="raw_data_bytes", values=("raw_data_bytes", "exp_data.raw_data_bytes"))
# Explore the basic (miniaturized) payload
exp_basic_payload_root = self.fields_tree.insert(exp_root, "end", iid="exp_basic_payload", text="Basic Payload (Mapped Part)")
self._recursive_populate_tree_ctypes(ds.ExpRawIf, exp_basic_payload_root, "exp_data.payload")
# --- PC Data ---
pc_root = self.fields_tree.insert(
"", "end", iid="pc_data", text="PC Block Data"
)
# Add the raw_data_bytes field for PC
self.fields_tree.insert(pc_root, "end", iid="pc_raw_bytes", text="raw_data_bytes", values=("raw_data_bytes", "pc_data.raw_data_bytes"))
# Explore the basic (miniaturized) payload
pc_basic_payload_root = self.fields_tree.insert(pc_root, "end", iid="pc_basic_payload", text="Basic Payload (Mapped Part)")
self._recursive_populate_tree_ctypes(ds.PcRawIf, pc_basic_payload_root, "pc_data.payload")
# --- DETECTOR Data ---
det_root = self.fields_tree.insert(
"", "end", iid="det_data", text="Detector Block Data"
)
# Add the raw_data_bytes field for Detector
self.fields_tree.insert(det_root, "end", iid="det_raw_bytes", text="raw_data_bytes", values=("raw_data_bytes", "det_data.raw_data_bytes"))
# Explore the basic (miniaturized) payload
det_basic_payload_root = self.fields_tree.insert(det_root, "end", iid="det_basic_payload", text="Basic Payload (Mapped Part)")
self._recursive_populate_tree_ctypes(ds.DetectorRawIf, det_basic_payload_root, "det_data.payload")
def _recursive_populate_properties(
self, class_obj: Type[Any], parent_id: str, base_path: str
):
# We need an actual instance to safely call properties,
# but creating a dummy one for every class might be complex.
# Instead, we just list properties by name from the class.
for name in dir(class_obj):
if name.startswith("_"):
continue
attr = getattr(class_obj, name)
# Check if it's a property (getter method)
if isinstance(attr, property):
# Optionally, we can check if it's a function, though `property` implies it.
# if inspect.isfunction(attr.fget): # Check if it has a getter
# We can add a hint to the display name or values tuple if this property
# is known to derive from specific raw fields.
# For D1553, this would be: (name, f"{base_path}.{name} (from payload.d.a4[XX])")
# For now, let's keep it generic, just indicating it's a property.
display_text = f"{name} (Property)"
# A common convention for properties is to put relevant raw data paths in their docstring.
# We could try to read it here:
# if attr.__doc__:
# # Example docstring: "Calculates latitude from payload.d.a4[23] and a4[24]"
# # You could parse this docstring for the raw data path.
# pass
self.fields_tree.insert(
parent_id,
"end",
iid=f"{parent_id}_{name}",
text=display_text,
values=(
name,
f"{base_path}.{name}",
), # The path is directly to the property
)
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()