# -*- coding: utf-8 -*- import tkinter as tk from tkinter import ttk import time import logging from typing import Any, Dict, Optional from .base_widgets import BaseCommandFrame, ToolTip from pymsc.utils.converters import ( RAD_TO_DEG, DEG_TO_RAD, NM_TO_METERS, METERS_TO_FEET, FEET_TO_METERS, MS_TO_KNOTS, KNOTS_TO_MS ) # Registry for finding widgets by their label (used for automation/scripts) WIDGET_MAP: Dict[str, Any] = {} def get_correct_value(info: Dict, idx: int, value: float) -> float: """ Applies LSB and Scale factors to convert a raw value to a readable value. Returns None if value is None (field not found). """ if value is None: return None suffix = str(idx) if idx != -1 else "" lsb = info.get(f"lsb{suffix}", 1.0) scale = info.get(f"scale{suffix}", 1.0) result = value * (scale * lsb) return result def set_correct_value(info: Dict, idx: int, value: float) -> int: """ Applies LSB and Scale factors to convert a readable value to a raw integer. """ suffix = str(idx) if idx != -1 else "" lsb = info.get(f"lsb{suffix}", 1.0) scale = info.get(f"scale{suffix}", 1.0) raw_val = value / (scale * lsb) return int(raw_val) class CommandFrameCheckBox(BaseCommandFrame): """ Widget containing a Label, a Command Checkbox, and a Tellback Checkbox. """ def __init__(self, parent: tk.Widget, command_info: Dict, **kwargs): super().__init__(parent, command_info, **kwargs) WIDGET_MAP[self.info['label']] = self self._create_ui() def _create_ui(self): self.label = tk.Label( self, text=self.info['label'], width=self.column_widths[0], anchor="w", font=("Helvetica", 8) ) self.label.grid(row=0, column=0, sticky="w", padx=5) self.tooltip = ToolTip(self.label, self.info['description']) if self.info.get('message'): self.command_var = tk.BooleanVar() self.command_check = tk.Checkbutton( self, variable=self.command_var, command=self.on_toggle, width=self.column_widths[1] ) self.command_check.grid(row=0, column=1) initial_val = self.info['message'].get_value_for_field(self.info['field']) self.command_var.set(bool(initial_val)) if self.info.get('message_tb'): self.tellback_var = tk.BooleanVar() self.tellback_check = tk.Checkbutton( self, variable=self.tellback_var, state='disabled', width=self.column_widths[2] ) self.tellback_check.grid(row=0, column=2) def on_toggle(self): # Skip if message is None (disabled field) if not self.info.get('message'): return self.start_time = time.time() val = 1 if self.command_var.get() else 0 self.info['message'].set_value_for_field(self.info['field'], val) # Log the change logger = logging.getLogger('ARTOS.MCS') state = "ON" if val == 1 else "OFF" logger.info(f"User changed '{self.info['label']}' → {state}") if self.script_manager.is_recording: action_val = "on" if val == 1 else "off" self.script_manager.write_command(self.info['label'], "toggle", action_val) if self.info.get('message_tb'): self.monitor_tellback() def monitor_tellback(self): msg_tb = self.info['message_tb'] field_tb = self.info['field_tb'] current_tb = bool(msg_tb.get_value_for_field(field_tb)) self.tellback_var.set(current_tb) elapsed = (time.time() - self.start_time) * 1000 if current_tb == self.command_var.get(): self.label.config(bg="green" if current_tb else "yellow") return if elapsed > self.threshold_ms: self.label.config(bg="red") return self.after(self.update_interval_ms, self.monitor_tellback) def check_updated_value(self): if self.info.get('message'): current_val = bool(self.info['message'].get_value_for_field(self.info['field'])) self.command_var.set(current_val) if self.info.get('message_tb'): current_tb = bool(self.info['message_tb'].get_value_for_field(self.info['field_tb'])) self.tellback_var.set(current_tb) class CommandFrameComboBox(BaseCommandFrame): """ Widget containing a Label, a Command ComboBox, and a Tellback Label. """ def __init__(self, parent: tk.Widget, command_info: Dict, **kwargs): super().__init__(parent, command_info, **kwargs) WIDGET_MAP[self.info['label']] = self self._create_ui() def _create_ui(self): self.label = tk.Label( self, text=self.info['label'], width=self.column_widths[0], anchor="w", font=("Helvetica", 8) ) self.label.grid(row=0, column=0, sticky="w", padx=5) self.tooltip = ToolTip(self.label, self.info['description']) self.cmd_var = tk.StringVar() self.combo = ttk.Combobox( self, textvariable=self.cmd_var, values=self.info["values"], width=self.column_widths[1], state="readonly" ) self.combo.grid(row=0, column=1) self.combo.bind("<>", self.on_select) if self.info.get('message_tb'): self.tb_var = tk.StringVar(value="---") self.tb_label = tk.Label( self, textvariable=self.tb_var, bg="white", width=self.column_widths[2], relief="sunken" ) self.tb_label.grid(row=0, column=2) def on_select(self, event=None): self.start_time = time.time() idx = self.combo.current() self.info['message'].set_value_for_field(self.info['field'], idx) # Log the change logger = logging.getLogger('ARTOS.MCS') logger.info(f"User changed '{self.info['label']}' → {self.combo.get()}") if self.script_manager.is_recording: self.script_manager.write_command(self.info['label'], "set_value", self.combo.get()) if self.info.get('message_tb'): self.monitor_tellback() def monitor_tellback(self): msg_tb = self.info['message_tb'] idx_tb = msg_tb.get_value_for_field(self.info['field_tb']) # Handle None or invalid index if idx_tb is None: self.tb_var.set("---") return try: val_tb_text = self.info["values_tb"][idx_tb] except (IndexError, KeyError, TypeError): val_tb_text = "ERR" self.tb_var.set(val_tb_text) elapsed = (time.time() - self.start_time) * 1000 if idx_tb == self.combo.current(): self.label.config(bg="green") return if elapsed > self.threshold_ms: self.label.config(bg="red") return self.after(self.update_interval_ms, self.monitor_tellback) def check_updated_value(self): if self.info.get('message'): current_idx = self.info['message'].get_value_for_field(self.info['field']) if current_idx is None: self.cmd_var.set("---") else: try: self.cmd_var.set(self.info['values'][current_idx]) except (IndexError, TypeError): self.cmd_var.set("ERR") if self.info.get('message_tb'): current_tb_idx = self.info['message_tb'].get_value_for_field(self.info['field_tb']) if current_tb_idx is None: self.tb_var.set("---") else: try: self.tb_var.set(self.info['values_tb'][current_tb_idx]) except (IndexError, TypeError): self.tb_var.set("ERR") class CommandFrameSpinBox(BaseCommandFrame): """ Widget containing a Label, a Command SpinBox, and a Tellback Label. Supports scaling (LSB/Scale) and numeric validation. """ def __init__(self, parent: tk.Widget, command_info: Dict, **kwargs): super().__init__(parent, command_info, **kwargs) WIDGET_MAP[self.info['label']] = self self.is_programmatic_change = False self.user_editing = False # Track if user is actively editing self._create_ui() def _create_ui(self): self.label = tk.Label( self, text=self.info['label'], width=self.column_widths[0], anchor="w", font=("Helvetica", 8) ) self.label.grid(row=0, column=0, sticky="w", padx=5) self.tooltip = ToolTip(self.label, self.info['description']) is_int = self.info.get("number_format", "float") == "integer" self.cmd_var = tk.IntVar() if is_int else tk.DoubleVar() self.cmd_var.set(self.info.get('default_value', 0)) v_cmd = (self.register(self._validate), '%P') self.spin = tk.Spinbox( self, textvariable=self.cmd_var, from_=self.info["min_value"], to=self.info["max_value"], increment=self.info.get("increment", 1), width=self.column_widths[1], validate="key", validatecommand=v_cmd ) self.spin.grid(row=0, column=1) self.spin.bind("", self.on_change) self.spin.bind("", self.on_change) # Track when user starts editing self.spin.bind("", self._on_focus_in) self.spin.bind("", self._on_key_press) # Detect arrow button clicks - send value immediately after arrow click self.spin.bind("", self._on_arrow_click) # Also use variable trace to detect arrow changes self.cmd_var.trace_add("write", self._on_var_change) if self.info.get('message_tb'): self.tb_var = tk.StringVar(value="0") self.tb_label = tk.Label( self, textvariable=self.tb_var, bg="white", width=self.column_widths[2], relief="sunken" ) self.tb_label.grid(row=0, column=2) def _validate(self, p): if p == "" or p == "-": return True try: float(p) return True except ValueError: return False def _on_focus_in(self, event=None): """Called when spinbox receives focus - user is about to edit""" self.user_editing = True def _on_key_press(self, event=None): """Called when user types - mark as editing""" self.user_editing = True def _on_arrow_click(self, event=None): """Called when user clicks spinbox arrows - send value immediately""" # Small delay to ensure the value has been updated by the spinbox self.after(50, self._send_arrow_value) def _on_var_change(self, *args): """Called when spinbox value changes (from arrows or typing)""" # If we're not programmatically changing and not already sending if not self.is_programmatic_change and not hasattr(self, '_sending_arrow_value'): # User changed value, mark as editing self.user_editing = True def _send_arrow_value(self): """Send the value after arrow click""" if self.is_programmatic_change: return self._sending_arrow_value = True try: val = float(self.cmd_var.get()) raw_val = set_correct_value(self.info, -1, val) self.info['message'].set_value_for_field(self.info['field'], raw_val) # Log the change logger = logging.getLogger('ARTOS.MCS') unit = self.info.get('unit', '') unit_str = f" {unit}" if unit else "" logger.info(f"User changed '{self.info['label']}' → {val}{unit_str}") # Clear editing flag after sending self.user_editing = False except (tk.TclError, ValueError): pass finally: delattr(self, '_sending_arrow_value') def on_change(self, event=None): if self.is_programmatic_change: return # Skip if message is None (disabled field) if not self.info.get('message'): return self.start_time = time.time() try: val = float(self.cmd_var.get()) except tk.TclError: self.user_editing = False # Clear flag even on error return raw_val = set_correct_value(self.info, -1, val) self.info['message'].set_value_for_field(self.info['field'], raw_val) # Log the change logger = logging.getLogger('ARTOS.MCS') unit = self.info.get('unit', '') unit_str = f" {unit}" if unit else "" logger.info(f"User changed '{self.info['label']}' → {val}{unit_str}") if self.script_manager.is_recording: self.script_manager.write_command(self.info['label'], "set_value", val) # Clear editing flag after successful change self.user_editing = False if self.info.get('message_tb'): self.monitor_tellback() def monitor_tellback(self): msg_tb = self.info['message_tb'] raw_tb = msg_tb.get_value_for_field(self.info['field_tb']) readable_tb = get_correct_value(self.info, -1, raw_tb) fmt = "%.0f" if self.info.get("number_format") == "integer" else "%.4f" self.tb_var.set(fmt % readable_tb) elapsed = (time.time() - self.start_time) * 1000 target = float(self.cmd_var.get()) if abs(readable_tb - target) < 0.0001: self.label.config(bg="green") return if elapsed > self.threshold_ms: self.label.config(bg="red") return self.after(self.update_interval_ms, self.monitor_tellback) def check_updated_value(self): # Skip if user is actively editing this field if self.user_editing: return # Skip if message is None (disabled field) if not self.info.get('message'): return self.is_programmatic_change = True raw_val = self.info['message'].get_value_for_field(self.info['field']) readable = get_correct_value(self.info, -1, raw_val) if readable is not None: self.cmd_var.set(readable) else: self.cmd_var.set(0) # Default value for None self.is_programmatic_change = False if self.info.get('message_tb'): raw_tb = self.info['message_tb'].get_value_for_field(self.info['field_tb']) readable_tb = get_correct_value(self.info, -1, raw_tb) if readable_tb is not None: fmt = "%.0f" if self.info.get("number_format") == "integer" else "%.4f" self.tb_var.set(fmt % readable_tb) else: self.tb_var.set("---") class CommandFrameLabels(tk.Frame): """ Widget for displaying up to 3 read-only values from the bus. """ def __init__(self, parent: tk.Widget, command_info: Dict, **kwargs): super().__init__(parent) self.info = command_info self.vars = {} self.column_widths = [20, 14, 14, 14] WIDGET_MAP[self.info['label']] = self self._create_ui() def _create_ui(self): self.label = tk.Label( self, text=self.info['label'], width=self.column_widths[0], anchor="w", font=("Helvetica", 8) ) self.label.grid(row=0, column=0, sticky="w", padx=5) ToolTip(self.label, self.info['description']) for i in range(1, 4): msg = self.info.get(f"message{i}") if msg: field = self.info[f"field{i}"] self.vars[field] = tk.StringVar(value="0") lbl = tk.Label( self, textvariable=self.vars[field], bg="#f0f0f0", width=self.column_widths[i], relief="groove", font=("Helvetica", 8) ) lbl.grid(row=0, column=i, padx=2) ToolTip(lbl, self.info.get(f"tooltip{i}", "")) def check_updated_value(self): for i in range(1, 4): msg = self.info.get(f"message{i}") if msg: field = self.info[f"field{i}"] raw = msg.get_value_for_field(field) val = get_correct_value(self.info, i, raw) if val is not None: self.vars[field].set("%.4f" % val) else: self.vars[field].set("N/A") class CommandFrameControls(tk.Frame): """ Highly flexible widget that can combine up to 3 sub-controls. """ def __init__(self, parent: tk.Widget, command_info: Dict, **kwargs): super().__init__(parent) self.info = command_info self.column_widths = [20, 14, 14, 14] self.vars = {} self.is_programmatic_change = False self.editing_fields = {} # Track which fields are being edited WIDGET_MAP[self.info['label']] = self self._create_ui() def _create_ui(self): self.title_label = tk.Label( self, text=self.info['label'], width=self.column_widths[0], anchor="w", font=("Helvetica", 8) ) self.title_label.grid(row=0, column=0, sticky="w", padx=5) ToolTip(self.title_label, self.info['description']) for i in range(1, 4): ctrl_type = self.info.get(f"type{i}") if not ctrl_type: continue self._add_sub_control(i, ctrl_type) def _add_sub_control(self, i: int, ctrl_type: str): field = self.info[f"field{i}"] msg = self.info.get(f"message{i}") if ctrl_type == "checkbox": self.vars[field] = tk.BooleanVar() lbl_text = self.info.get(f"label{i}", "") cb = tk.Checkbutton( self, text=lbl_text, variable=self.vars[field], width=self.column_widths[i], command=lambda f=field, m=msg: self._on_check(f, m) ) cb.grid(row=0, column=i) WIDGET_MAP[field] = {"widget": cb, "var": self.vars[field]} elif ctrl_type == "combobox": self.vars[field] = tk.StringVar() vals = self.info.get(f"values{i}", []) cmb = ttk.Combobox( self, textvariable=self.vars[field], values=vals, width=self.column_widths[i], state="readonly" ) cmb.grid(row=0, column=i) cmb.bind("<>", lambda e, f=field, m=msg, idx=i: self._on_combo(f, m, idx)) WIDGET_MAP[field] = {"widget": cmb, "var": self.vars[field]} elif ctrl_type == "spinbox": number_format = self.info.get(f"number_format{i}", "float") is_int = number_format == "integer" self.vars[field] = tk.IntVar() if is_int else tk.DoubleVar() self.editing_fields[field] = False # Initialize editing state fmt = '%.0f' if is_int else '%.4f' min_v = self.info.get(f"min_value{i}", -999999) max_v = self.info.get(f"max_value{i}", 999999) inc_v = self.info.get(f"increment{i}", 1) sp = tk.Spinbox( self, textvariable=self.vars[field], width=self.column_widths[i], from_=min_v, to=max_v, increment=inc_v, format=fmt ) sp.grid(row=0, column=i) sp.bind("", lambda e, f=field, m=msg, idx=i: self._on_spin(f, m, idx)) sp.bind("", lambda e, f=field, m=msg, idx=i: self._on_spin(f, m, idx)) # Track editing state sp.bind("", lambda e, f=field: self._set_editing(f, True)) sp.bind("", lambda e, f=field: self._set_editing(f, True)) # Detect arrow button clicks sp.bind("", lambda e, f=field, m=msg, idx=i: self._on_spin_arrow(f, m, idx)) WIDGET_MAP[field] = {"widget": sp, "var": self.vars[field]} elif ctrl_type == "label": self.vars[field] = tk.StringVar(value="0") lbl = tk.Label( self, textvariable=self.vars[field], relief="groove", width=self.column_widths[i], anchor="w", font=("Helvetica", 8) ) lbl.grid(row=0, column=i) elif ctrl_type == "space": lbl = tk.Label(self, text="", width=self.column_widths[i]) lbl.grid(row=0, column=i) def _set_editing(self, field: str, is_editing: bool): """Track editing state for a specific field""" self.editing_fields[field] = is_editing def _on_check(self, field: str, msg: Any): val = 1 if self.vars[field].get() else 0 msg.set_value_for_field(field, val) from pymsc.gui.script_manager import get_script_manager sm = get_script_manager() if sm.is_recording: sm.write_command(field, "toggle", "on" if val else "off") def _on_combo(self, field: str, msg: Any, idx: int): cmb = WIDGET_MAP[field]["widget"] val_idx = cmb.current() msg.set_value_for_field(field, val_idx) from pymsc.gui.script_manager import get_script_manager sm = get_script_manager() if sm.is_recording: sm.write_command(field, "set_value", cmb.get()) def _on_spin(self, field: str, msg: Any, idx: int): if self.is_programmatic_change: return val = float(self.vars[field].get()) raw = set_correct_value(self.info, idx, val) msg.set_value_for_field(field, raw) # Clear editing flag after change self.editing_fields[field] = False from pymsc.gui.script_manager import get_script_manager sm = get_script_manager() if sm.is_recording: sm.write_command(field, "set_value", val) def _on_spin_arrow(self, field: str, msg: Any, idx: int): """Handle spinbox arrow button clicks - send value immediately with small delay""" # Small delay to ensure the value has been updated self.after(50, lambda: self._send_spin_arrow_value(field, msg, idx)) def _send_spin_arrow_value(self, field: str, msg: Any, idx: int): """Send spinbox value after arrow click""" if self.is_programmatic_change: return try: val = float(self.vars[field].get()) raw = set_correct_value(self.info, idx, val) msg.set_value_for_field(field, raw) # Clear editing flag self.editing_fields[field] = False except (ValueError, tk.TclError): pass def check_updated_value(self): """ Syncs sub-controls with their corresponding 1553 message fields. Renamed from check_actual_value for consistency with MainWindow refresh loop. """ self.is_programmatic_change = True for i in range(1, 4): ctrl_type = self.info.get(f"type{i}") if not ctrl_type or ctrl_type == "space": continue field = self.info.get(f"field{i}") msg = self.info.get(f"message{i}") if not msg or not field: continue # Skip spinbox refresh if user is actively editing if ctrl_type == "spinbox" and self.editing_fields.get(field, False): continue raw = msg.get_value_for_field(field) readable = get_correct_value(self.info, i, raw) if ctrl_type == "checkbox": self.vars[field].set(bool(raw)) elif ctrl_type == "combobox": try: self.vars[field].set(self.info[f"values{i}"][int(raw)]) except IndexError: self.vars[field].set("ERR") elif ctrl_type == "spinbox": self.vars[field].set(readable) elif ctrl_type == "label": self.vars[field].set("%.4f" % readable) self.is_programmatic_change = False