467 lines
20 KiB
Python
467 lines
20 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
|
|
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()
|
|
# 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("<<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)
|
|
# --- 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("<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": # 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() |