From 504fa1bffbf660968792edbae4462d1d2d34ced5 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Sat, 19 Jul 2025 15:47:23 -0500 Subject: [PATCH 01/10] Adding stage limits popup --- .../controller/sub_controllers/__init__.py | 1 + .../controller/sub_controllers/menus.py | 28 +++- .../sub_controllers/stages_advanced.py | 75 +++++++++ .../view/popups/stages_advanced_popup.py | 148 ++++++++++++++++++ 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/navigate/controller/sub_controllers/stages_advanced.py create mode 100644 src/navigate/view/popups/stages_advanced_popup.py diff --git a/src/navigate/controller/sub_controllers/__init__.py b/src/navigate/controller/sub_controllers/__init__.py index 43b4b583d..737ff0258 100644 --- a/src/navigate/controller/sub_controllers/__init__.py +++ b/src/navigate/controller/sub_controllers/__init__.py @@ -19,6 +19,7 @@ from .microscope_popup import MicroscopePopupController # noqa from .adaptive_optics import AdaptiveOpticsPopupController # noqa from .histogram import HistogramController # noqa +from .stages_advanced import StageLimitsController # noqa # from .uninstall_plugin_controller import UninstallPluginController # noqa from .plugins import PluginsController, UninstallPluginController # noqa diff --git a/src/navigate/controller/sub_controllers/menus.py b/src/navigate/controller/sub_controllers/menus.py index d53e9807f..1e76448f0 100644 --- a/src/navigate/controller/sub_controllers/menus.py +++ b/src/navigate/controller/sub_controllers/menus.py @@ -43,7 +43,7 @@ # Third Party Imports -# Local Imports +# Local View Imports from navigate.view.popups.ilastik_setting_popup import ilastik_setting_popup from navigate.view.popups.autofocus_setting_popup import AutofocusPopup from navigate.view.popups.adaptiveoptics_popup import AdaptiveOpticsPopup @@ -53,6 +53,9 @@ ) from navigate.view.popups.feature_list_popup import FeatureListPopup from navigate.view.popups.camera_setting_popup import CameraSettingPopup +from navigate.view.popups.stages_advanced_popup import StageLimitsPopup + +# Local Controller Imports from navigate.controller.sub_controllers.gui import GUIController from navigate.controller.sub_controllers import ( AutofocusPopupController, @@ -65,7 +68,10 @@ FeatureAdvancedSettingController, AdaptiveOpticsPopupController, UninstallPluginController, + StageLimitsController, ) + +# Local Tools Imports from navigate.tools.file_functions import save_yaml_file, load_yaml_file from navigate.tools.decorators import FeatureList from navigate.tools.common_functions import load_module_from_file, combine_funcs @@ -359,6 +365,13 @@ def initialize_menus(self): None, ], "add_separator_1": [None, None, None, None, None], + "Set Stage Limits": [ + "standard", + self.stage_limits_popup, + None, + None, + None, + ], }, } self.populate_menu(stage_control_menu) @@ -1422,3 +1435,16 @@ def func(*args): ) return func + + def stage_limits_popup(self, *args, **kwargs) -> None: + """Pop up the Stage Limits setting window.""" + if hasattr(self.parent_controller, "stage_limits_popup_controller"): + self.parent_controller.stage_limits_popup_controller.showup() + return + popup = StageLimitsPopup(self.view) + stage_limits_controller = StageLimitsController(popup, self.parent_controller) + setattr( + self.parent_controller, + "stage_limits_popup_controller", + stage_limits_controller, + ) diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py new file mode 100644 index 000000000..cad2f27b3 --- /dev/null +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -0,0 +1,75 @@ +# Copyright (c) 2021-2024 The University of Texas Southwestern Medical Center. +# All rights reserved. +from sphinx.cmd.quickstart import suffix + + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted for academic and research use only (subject to the +# limitations in the disclaimer below) provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. + +# * Neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. + +# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +class StageLimitsController: + """Controller for the Stage Limits popup.""" + + def __init__(self, popup, parent_controller, *args, **kwargs): + """Initialize the StageLimitsController class. + + Parameters + ---------- + root : tk.Tk + The root window + popup : PopUp + The popup window for stage limits + parent_controller : Controller + The parent controller that manages this popup + *args + Variable length argument list + **kwargs + Arbitrary keyword arguments + """ + + #: list: List of stages available in the system. + self.num_stages = parent_controller.configuration_controller.all_stage_axes + + #: dict: List of minimum limits for each stage. + self.min_limits = ( + parent_controller.configuration_controller.get_stage_limits_min_limits( + suffix="_min" + ) + ) + + #: dict: List of maximum limits for each stage. + self.max_limits = ( + parent_controller.configuration_controller.get_stage_limits_max_limits( + suffix="_max" + ) + ) + + #: PopUp: Popup window for the stage limits. + self.view = popup + + self.view.populate_view(self.num_stages) diff --git a/src/navigate/view/popups/stages_advanced_popup.py b/src/navigate/view/popups/stages_advanced_popup.py new file mode 100644 index 000000000..72e8a9831 --- /dev/null +++ b/src/navigate/view/popups/stages_advanced_popup.py @@ -0,0 +1,148 @@ +# Copyright (c) 2021-2024 The University of Texas Southwestern Medical Center. +# All rights reserved. +# Redistribution and use in source and binary forms, with or without +# modification, are permitted for academic and research use only (subject to the +# limitations in the disclaimer below) provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. + +# * Neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. + +# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# Standard Library Imports +import tkinter as tk + +# Local Imports +from navigate.view.custom_widgets.popup import PopUp + + +class StageLimitsPopup: + """Class creates the popup to set stage limits.""" + + def __init__(self, root, *args, **kwargs): + """Initialize the CameraSettingPopup class. + + Parameters + ---------- + root : tkinter.Tk + Root window of the application. + args : list + List of arguments. + kwargs : dict + Dictionary of keyword arguments. + """ + # Creating popup window with this name and size/placement, PopUp is a + # Toplevel window + #: PopUp: Popup window for the camera view. + self.popup = PopUp( + root, + name="Stage Limits", + size="+320+180", + top=False, + transient=False, + ) + self.popup.resizable(tk.TRUE, tk.TRUE) + + # Creating the frame for the popup + self.frame = self.popup.content_frame + + # Add tk labels to the frame + # Column 1, stage identity. Column 2, minimum limit. Column 3, update button. + # Column 4, maximum limit. Column 5, update button. + + # Create column headers + tk.Label(self.frame, text="Stage", font=("Arial", 10, "bold")).grid( + row=0, column=0, padx=5, pady=5, sticky="NSEW" + ) + tk.Label(self.frame, text="Minimum Limit", font=("Arial", 10, "bold")).grid( + row=0, column=1, columnspan=2, padx=5, pady=5, sticky="NSEW" + ) + tk.Label(self.frame, text="Maximum Limit", font=("Arial", 10, "bold")).grid( + row=0, column=3, columnspan=2, padx=5, pady=5, sticky="NSEW" + ) + + # Trace for when the popup is closed + self.popup.protocol("WM_DELETE_WINDOW", self.close_popup) + + def populate_view(self, stages): + """Populate the view with the stages. + + Add the widgets to the view for each stage in alphabetical order. + Creates a row for each stage with: stage name, min limit spinbox, + update min button, max limit spinbox, and update max button. + + Parameters + ---------- + stages : list + List of stage names as strings. + """ + # Sort stages alphabetically + sorted_stages = sorted(stages) + + # Create a row for each stage + for i, stage_name in enumerate(sorted_stages, start=1): + # Column 1: Stage name label + tk.Label(self.frame, text=stage_name).grid( + row=i, column=0, padx=5, pady=2, sticky="w" + ) + + # Column 2: Minimum limit spinbox + min_spinbox = tk.Spinbox( + self.frame, + from_=-10000, + to=10000, + width=10, + format="%.3f", + increment=0.1, + ) + min_spinbox.grid(row=i, column=1, padx=5, pady=2) + + # Column 3: Update minimum button + update_min_btn = tk.Button(self.frame, text="Update", width=8) + update_min_btn.grid(row=i, column=2, padx=5, pady=2) + + # Column 4: Maximum limit spinbox + max_spinbox = tk.Spinbox( + self.frame, + from_=-10000, + to=10000, + width=10, + format="%.3f", + increment=0.1, + ) + max_spinbox.grid(row=i, column=3, padx=5, pady=2) + + # Column 5: Update maximum button + update_max_btn = tk.Button(self.frame, text="Update", width=8) + update_max_btn.grid(row=i, column=4, padx=5, pady=2) + + def close_popup(self): + """Close the popup window.""" + self.popup.destroy() + + +if __name__ == "__main__": + root = tk.Tk() + root.withdraw() # Hide the root window + popup = StageLimitsPopup(root) + popup.populate_view(["Stage 1", "Stage 2", "Stage 3"]) + popup.popup.mainloop() From fb95e5bfed723edae72b949ada3c1321a2e92d08 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Sun, 20 Jul 2025 15:43:32 -0500 Subject: [PATCH 02/10] Stage Limits Controller Populating the stage limits controller. Currently the menu and the popup are opposite of each other. Also the updated values aren't reflected in the configuration.yaml file. --- .../sub_controllers/stages_advanced.py | 75 ++++++++++++- .../view/main_window_content/channels_tab.py | 77 +++++++++---- .../view/popups/stages_advanced_popup.py | 104 ++++++++++++++---- 3 files changed, 211 insertions(+), 45 deletions(-) diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index cad2f27b3..e0a19f353 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -52,19 +52,22 @@ def __init__(self, popup, parent_controller, *args, **kwargs): Arbitrary keyword arguments """ + # Initialize the parent controller + self.parent_controller = parent_controller + #: list: List of stages available in the system. - self.num_stages = parent_controller.configuration_controller.all_stage_axes + self.num_stages = self.parent_controller.configuration_controller.all_stage_axes #: dict: List of minimum limits for each stage. self.min_limits = ( - parent_controller.configuration_controller.get_stage_limits_min_limits( + self.parent_controller.configuration_controller.get_stage_position_limits( suffix="_min" ) ) #: dict: List of maximum limits for each stage. self.max_limits = ( - parent_controller.configuration_controller.get_stage_limits_max_limits( + self.parent_controller.configuration_controller.get_stage_position_limits( suffix="_max" ) ) @@ -72,4 +75,68 @@ def __init__(self, popup, parent_controller, *args, **kwargs): #: PopUp: Popup window for the stage limits. self.view = popup - self.view.populate_view(self.num_stages) + # Initialize the view with the number of stages and their limits + self.view.populate_view(self.num_stages, self.min_limits, self.max_limits) + + # Get the buttons from the view and set their commands + self.view.save_button.configure(command=self.save_stage_limits) + self.view.stage_limits_enabled.configure(command=self.toggle_limits) + for key, value in self.view.buttons.items(): + value.configure(command=lambda k=key: self.update_axis(k)) + + # Configure traces for closing the window or pressing escape. + self.view.popup.protocol("WM_DELETE_WINDOW", self.close_popup) + self.view.popup.bind("", lambda event: self.close_popup()) + + # See if the stage limits are currently enabled or disabled. + self.stage_limits_enabled = self.parent_controller.stage_controller.stage_limits + self.view.enable_stage_limits_var.set(not self.stage_limits_enabled) + + def save_stage_limits(self): + print("Saving limits...") + + def toggle_limits(self): + """Toggle the stage limits on or off.""" + + # Get the current state of the checkbox. + limits_enabled = self.view.enable_stage_limits_var.get() + + # Update the stage controller with the new state. + self.parent_controller.stage_controller.stage_limits = limits_enabled + + # Update the menu controller to reflect the change. + self.parent_controller.menu_controller.disable_stage_limits.set(limits_enabled) + + print("Toggling limits...") + + def update_axis(self, axis): + """Get the current stage position, and update the stage limits in the configuration. + + axis: str + The stage limit to update, e.g., 'x_min', 'y_max', etc. + """ + + # Identify the axis and whether it's a minimum or maximum limit. + axis, min_or_max = axis.split("_") + + # Get our current position. + self.parent_controller.execute("query_stages") + current_position = self.parent_controller.stage_controller.get_position() + + # Update the popup window. + self.view.spinboxes[f"{axis}_{min_or_max}"].set(current_position[axis]) + + # Update the configuration. + self.parent_controller.configuration_controller.microscope_config["stage"][ + f"{axis}_{min_or_max}" + ] = current_position[axis] + + print(f"Updating {axis} {min_or_max} limits to {current_position[axis]}...") + + def close_popup(self): + """Close the popup window.""" + self.save_stage_limits() + self.view.popup.destroy() + + if hasattr(self.parent_controller, "stage_limits_popup_controller"): + del self.parent_controller.stage_limits_popup_controller diff --git a/src/navigate/view/main_window_content/channels_tab.py b/src/navigate/view/main_window_content/channels_tab.py index aa168b383..2b54a49e7 100644 --- a/src/navigate/view/main_window_content/channels_tab.py +++ b/src/navigate/view/main_window_content/channels_tab.py @@ -498,7 +498,9 @@ def __init__(self, settings_tab: ChannelsTab, *args: list, **kwargs: dict) -> No input_args={"width": 8}, ) self.inputs["z_device"].state(["disabled", "readonly"]) - self.inputs["z_device"].grid(row=4, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5) + self.inputs["z_device"].grid( + row=4, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5 + ) self.inputs["f_device"] = LabelInput( parent=self.stack_frame, @@ -508,7 +510,9 @@ def __init__(self, settings_tab: ChannelsTab, *args: list, **kwargs: dict) -> No input_args={"width": 8}, ) self.inputs["f_device"].state(["disabled", "readonly"]) - self.inputs["f_device"].grid(row=5, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5) + self.inputs["f_device"].grid( + row=5, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5 + ) # Laser Cycling Settings self.inputs["cycling"] = LabelInput( @@ -519,11 +523,21 @@ def __init__(self, settings_tab: ChannelsTab, *args: list, **kwargs: dict) -> No input_args={"width": 8}, ) self.inputs["cycling"].state(["readonly"]) - self.inputs["cycling"].grid(row=6, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5) + self.inputs["cycling"].grid( + row=6, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5 + ) self.cubic_frame = ttk.Frame(self.stack_frame) - self.cubic_frame.grid(row=3, rowspan=3, column=2, columnspan=2, sticky=tk.NE, padx=(5, 15), pady=(5, 0)) - + self.cubic_frame.grid( + row=3, + rowspan=3, + column=2, + columnspan=2, + sticky=tk.NE, + padx=(5, 15), + pady=(5, 0), + ) + image_directory = Path(__file__).resolve().parent self.image = tk.PhotoImage( @@ -532,7 +546,15 @@ def __init__(self, settings_tab: ChannelsTab, *args: list, **kwargs: dict) -> No # Use ttk.Label self.cubic_image_label = ttk.Label(self.cubic_frame, image=self.image) - self.cubic_image_label.grid(row=0, rowspan=2, column=0, columnspan=2, sticky=tk.NSEW, padx=(5, 0), pady=(5, 0)) + self.cubic_image_label.grid( + row=0, + rowspan=2, + column=0, + columnspan=2, + sticky=tk.NSEW, + padx=(5, 0), + pady=(5, 0), + ) self.inputs["top"] = LabelInput( parent=self.cubic_frame, @@ -562,7 +584,9 @@ def __init__(self, settings_tab: ChannelsTab, *args: list, **kwargs: dict) -> No input_args={"width": 8}, ) self.inputs["z_offset"].widget.configure(state="disabled") - self.inputs["z_offset"].grid(row=0, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5) + self.inputs["z_offset"].grid( + row=0, column=0, columnspan=2, sticky="NSEW", padx=6, pady=5 + ) uniform_grid(self) @@ -579,10 +603,10 @@ def __init__(self, settings_tab: ChannelsTab, *args: list, **kwargs: dict) -> No "The relative offset between the Z stages, if applicable." ) self.buttons["set_end"].hover.setdescription( - "Sets the Z-stack end position " "for the F and Z Axes." + "Sets the Z-stack end position for the F and Z Axes." ) self.buttons["set_start"].hover.setdescription( - "Sets the Z-stack start position " "for the F and Z Axes." + "Sets the Z-stack start position for the F and Z Axes." ) self.inputs["z_device"].widget.hover.setdescription( "The device that controls the Z-stack." @@ -630,7 +654,7 @@ def create_additional_stack_widgets(self, axes: list, devices: dict) -> None: if len(axes) <= 2: return - + self.devices_dict = devices # Create the additional stack widgets here @@ -649,23 +673,35 @@ def create_additional_stack_widgets(self, axes: list, devices: dict) -> None: command=self.update_setting_widgets(axis), variable=self.additional_stack_setting_variables[f"stack_{axis}"], ) - self.inputs[f"stack_{axis}"].grid(row=1, column=axes.index(axis) + 1, sticky=tk.NW, padx=(5, 10), pady=(5, 0)) + self.inputs[f"stack_{axis}"].grid( + row=1, + column=axes.index(axis) + 1, + sticky=tk.NW, + padx=(5, 10), + pady=(5, 0), + ) self.additional_stack_setting_frame = ttk.Frame(self.additional_stack_frame) - self.additional_stack_setting_frame.grid(row=2, column=0, columnspan=10, sticky=tk.NSEW, padx=(5, 30), pady=(5, 0)) + self.additional_stack_setting_frame.grid( + row=2, column=0, columnspan=10, sticky=tk.NSEW, padx=(5, 30), pady=(5, 0) + ) self.additional_stack_setting_labels = {} - for i, label_text in enumerate(["Axis", "Device", "Offset (" + "\N{GREEK SMALL LETTER MU}" + "m)"]): #, "Step", "Slice Num"]): + for i, label_text in enumerate( + ["Axis", "Device", "Offset (" + "\N{GREEK SMALL LETTER MU}" + "m)"] + ): # , "Step", "Slice Num"]): label = ttk.Label(self.additional_stack_setting_frame, text=label_text) label.grid(row=0, column=i, sticky=tk.NSEW, padx=10, pady=2) for i, axis in enumerate(axes): label = ttk.Label(self.additional_stack_setting_frame, text=axis.upper()) - label.grid(row=i+1, column=0, sticky=tk.NSEW, padx=10, pady=2) + label.grid(row=i + 1, column=0, sticky=tk.NSEW, padx=10, pady=2) label.grid_remove() self.additional_stack_setting_labels[axis] = label # Create the device label - label = ttk.Label(self.additional_stack_setting_frame, text=self.devices_dict[axis]) - label.grid(row=i+1, column=1, sticky=tk.NSEW, padx=10, pady=2) + label = ttk.Label( + self.additional_stack_setting_frame, text=self.devices_dict[axis] + ) + label.grid(row=i + 1, column=1, sticky=tk.NSEW, padx=10, pady=2) label.grid_remove() self.additional_stack_setting_labels[f"{axis}_device"] = label # Create the offset spinbox @@ -675,9 +711,11 @@ def create_additional_stack_widgets(self, axes: list, devices: dict) -> None: master=self.additional_stack_setting_frame, from_=-10000, to=10000, - textvariable=self.additional_stack_setting_variables[index_name] + textvariable=self.additional_stack_setting_variables[index_name], + ) + self.inputs[index_name].grid( + row=i + 1, column=2, sticky=tk.NSEW, padx=10, pady=2 ) - self.inputs[index_name].grid(row=i+1, column=2, sticky=tk.NSEW, padx=10, pady=2) self.inputs[index_name].grid_remove() self.additional_stack_setting_frame.grid_remove() @@ -695,6 +733,7 @@ def update_setting_widgets(self, axis: str) -> None: axis : str The axis to update the widgets for. """ + def func(*args: list) -> None: """Inner function to update the widgets. @@ -718,7 +757,7 @@ def func(*args: list) -> None: self.additional_stack_setting_frame.grid_remove() return func - + # Getters def get_variables(self) -> dict: """Returns a dictionary of the variables in the widget diff --git a/src/navigate/view/popups/stages_advanced_popup.py b/src/navigate/view/popups/stages_advanced_popup.py index 72e8a9831..5bced29a6 100644 --- a/src/navigate/view/popups/stages_advanced_popup.py +++ b/src/navigate/view/popups/stages_advanced_popup.py @@ -31,8 +31,11 @@ # Standard Library Imports import tkinter as tk +from navigate.view.custom_widgets.hover import HoverButton + # Local Imports from navigate.view.custom_widgets.popup import PopUp +from navigate.view.custom_widgets.validation import ValidatedSpinbox class StageLimitsPopup: @@ -80,10 +83,22 @@ def __init__(self, root, *args, **kwargs): row=0, column=3, columnspan=2, padx=5, pady=5, sticky="NSEW" ) - # Trace for when the popup is closed - self.popup.protocol("WM_DELETE_WINDOW", self.close_popup) + #: dict: Dictionary to hold the buttons for updating limits. + self.buttons = {} + + #: dict: Dictionary to hold the spinboxes for stage limits. + self.spinboxes = {} + + #: BooleanVar: Variable to hold the state of the stage limits checkbox. + self.enable_stage_limits_var = None + + #: Checkbutton: Checkbox for the stage limits. + self.stage_limits_enabled = None + + #: HoverButton: Button to save the limits. + self.save_button = None - def populate_view(self, stages): + def populate_view(self, stages, min, max): """Populate the view with the stages. Add the widgets to the view for each stage in alphabetical order. @@ -93,20 +108,27 @@ def populate_view(self, stages): Parameters ---------- stages : list - List of stage names as strings. + The list of stage names as strings. + min : dict + A dictionary containing the minimum limits for each stage. + max : dict + A dictionary containing the maximum limits for each stage. """ + button_width = 6 + # Sort stages alphabetically sorted_stages = sorted(stages) # Create a row for each stage for i, stage_name in enumerate(sorted_stages, start=1): + # Column 1: Stage name label tk.Label(self.frame, text=stage_name).grid( row=i, column=0, padx=5, pady=2, sticky="w" ) # Column 2: Minimum limit spinbox - min_spinbox = tk.Spinbox( + self.spinboxes[stage_name + "_min"] = ValidatedSpinbox( self.frame, from_=-10000, to=10000, @@ -114,14 +136,24 @@ def populate_view(self, stages): format="%.3f", increment=0.1, ) - min_spinbox.grid(row=i, column=1, padx=5, pady=2) + self.spinboxes[stage_name + "_min"].set(min.get(stage_name, 0.0)) + self.spinboxes[stage_name + "_min"].grid(row=i, column=1, padx=5, pady=2) + self.spinboxes[stage_name + "_min"].hover.setdescription( + "The desired minimum limit for the stage." + ) # Column 3: Update minimum button - update_min_btn = tk.Button(self.frame, text="Update", width=8) - update_min_btn.grid(row=i, column=2, padx=5, pady=2) + self.buttons[stage_name + "_min"] = HoverButton( + self.frame, text="Update", width=button_width + ) + self.buttons[stage_name + "_min"].grid(row=i, column=2, padx=5, pady=2) + self.buttons[stage_name + "_min"].hover.setdescription( + "Click to update the minimum limit for this stage to the current " + "position." + ) # Column 4: Maximum limit spinbox - max_spinbox = tk.Spinbox( + self.spinboxes[stage_name + "_max"] = ValidatedSpinbox( self.frame, from_=-10000, to=10000, @@ -129,20 +161,48 @@ def populate_view(self, stages): format="%.3f", increment=0.1, ) - max_spinbox.grid(row=i, column=3, padx=5, pady=2) + self.spinboxes[stage_name + "_max"].set(min.get(stage_name, 0.0)) + self.spinboxes[stage_name + "_max"].grid(row=i, column=3, padx=5, pady=2) + self.spinboxes[stage_name + "_max"].hover.setdescription( + "The desired maximum limit for the stage." + ) # Column 5: Update maximum button - update_max_btn = tk.Button(self.frame, text="Update", width=8) - update_max_btn.grid(row=i, column=4, padx=5, pady=2) - - def close_popup(self): - """Close the popup window.""" - self.popup.destroy() + self.buttons[stage_name + "_max"] = HoverButton( + self.frame, text="Update", width=button_width + ) + self.buttons[stage_name + "_max"].grid(row=i, column=4, padx=5, pady=2) + self.buttons[stage_name + "_max"].hover.setdescription( + "Click to update the maximum limit for this stage to the current " + "position." + ) + # Provide a checkbox to disable the stage limits. + self.enable_stage_limits_var = tk.BooleanVar() + self.stage_limits_enabled = tk.Checkbutton( + self.frame, + text="Stage Limits Enabled", + variable=self.enable_stage_limits_var, + ) + self.stage_limits_enabled.grid( + row=len(sorted_stages) + 1, + column=0, + columnspan=2, + padx=5, + pady=5, + sticky="w", + ) -if __name__ == "__main__": - root = tk.Tk() - root.withdraw() # Hide the root window - popup = StageLimitsPopup(root) - popup.populate_view(["Stage 1", "Stage 2", "Stage 3"]) - popup.popup.mainloop() + # Save button. + self.save_button = HoverButton(self.frame, text="Save", width=button_width) + self.save_button.grid( + row=len(sorted_stages) + 1, + column=4, + columnspan=1, + padx=5, + pady=5, + sticky="e", + ) + self.save_button.hover.setdescription( + "Click to save the limits for all stages." + ) From 79720296a269fc19a309e169d6c87da4850b3772 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Mon, 21 Jul 2025 06:09:08 -0500 Subject: [PATCH 03/10] Sync stage limits state between menu and popup Updated MenuController and StageLimitsController to ensure stage limits state is consistently reflected in both the main menu and the stage limits popup. Added logic to update the popup's checkbox when toggling limits from the menu, and improved event handling for enabling/disabling limits from the popup. --- .../controller/sub_controllers/menus.py | 16 ++++++++--- .../sub_controllers/stages_advanced.py | 28 +++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/navigate/controller/sub_controllers/menus.py b/src/navigate/controller/sub_controllers/menus.py index 1e76448f0..779e99a5f 100644 --- a/src/navigate/controller/sub_controllers/menus.py +++ b/src/navigate/controller/sub_controllers/menus.py @@ -1015,17 +1015,25 @@ def popup_help(self) -> None: def toggle_stage_limits(self, *args) -> None: """Toggle stage limits.""" if self.disable_stage_limits.get() == 1: + limits_enabled = False self.parent_controller.configuration["experiment"]["StageParameters"][ "limits" - ] = False + ] = limits_enabled logger.debug("Disabling stage limits") - self.parent_controller.execute("stage_limits", False) + self.parent_controller.execute("stage_limits", limits_enabled) else: + limits_enabled = True self.parent_controller.configuration["experiment"]["StageParameters"][ "limits" - ] = True + ] = limits_enabled logger.debug("Enabling stage limits") - self.parent_controller.execute("stage_limits", True) + self.parent_controller.execute("stage_limits", limits_enabled) + + # If the stage limits popup is open, update it. + if hasattr(self.parent_controller, "stage_limits_popup_controller"): + self.parent_controller.stage_limits_popup_controller.view.enable_stage_limits_var.set( + limits_enabled + ) @log_function_call def popup_autofocus_setting(self, *args) -> None: diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index e0a19f353..1f9c32553 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -1,8 +1,5 @@ # Copyright (c) 2021-2024 The University of Texas Southwestern Medical Center. # All rights reserved. -from sphinx.cmd.quickstart import suffix - - # Redistribution and use in source and binary forms, with or without # modification, are permitted for academic and research use only (subject to the # limitations in the disclaimer below) provided that the following conditions are met: @@ -31,6 +28,8 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from navigate.config.config import update_config_dict + class StageLimitsController: """Controller for the Stage Limits popup.""" @@ -78,9 +77,10 @@ def __init__(self, popup, parent_controller, *args, **kwargs): # Initialize the view with the number of stages and their limits self.view.populate_view(self.num_stages, self.min_limits, self.max_limits) - # Get the buttons from the view and set their commands + # Save button trace. self.view.save_button.configure(command=self.save_stage_limits) - self.view.stage_limits_enabled.configure(command=self.toggle_limits) + + # Configure the spinboxes for each stage limit. for key, value in self.view.buttons.items(): value.configure(command=lambda k=key: self.update_axis(k)) @@ -90,24 +90,28 @@ def __init__(self, popup, parent_controller, *args, **kwargs): # See if the stage limits are currently enabled or disabled. self.stage_limits_enabled = self.parent_controller.stage_controller.stage_limits - self.view.enable_stage_limits_var.set(not self.stage_limits_enabled) + self.view.enable_stage_limits_var.set(self.stage_limits_enabled) + + # Checkbox trace for enabling/disabling stage limits. + self.view.enable_stage_limits_var.trace_add("write", self.toggle_limits) def save_stage_limits(self): print("Saving limits...") - def toggle_limits(self): + def toggle_limits(self, *args): """Toggle the stage limits on or off.""" # Get the current state of the checkbox. limits_enabled = self.view.enable_stage_limits_var.get() # Update the stage controller with the new state. - self.parent_controller.stage_controller.stage_limits = limits_enabled - - # Update the menu controller to reflect the change. - self.parent_controller.menu_controller.disable_stage_limits.set(limits_enabled) + self.parent_controller.execute("stage_limits", limits_enabled) - print("Toggling limits...") + # Update the menu item state. + if limits_enabled is True: + self.parent_controller.menu_controller.disable_stage_limits.set(0) + else: + self.parent_controller.menu_controller.disable_stage_limits.set(1) def update_axis(self, axis): """Get the current stage position, and update the stage limits in the configuration. From 45588244324eccaa27e9ad3674797e0651118e51 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Mon, 21 Jul 2025 07:39:19 -0500 Subject: [PATCH 04/10] Refactor stage limits to advanced stage parameters Renamed StageLimitsController and StageLimitsPopup to AdvancedStageParametersController and AdvancedStageParametersPopup, respectively. Updated all references and improved UI to include microscope selection and enhanced layout. Added logging and refactored controller logic for better maintainability. --- src/navigate/config/config.py | 38 ++++----- .../controller/sub_controllers/__init__.py | 2 +- .../controller/sub_controllers/menus.py | 12 +-- .../sub_controllers/stages_advanced.py | 85 +++++++++++++++++-- .../view/popups/stages_advanced_popup.py | 81 +++++++++++------- 5 files changed, 151 insertions(+), 67 deletions(-) diff --git a/src/navigate/config/config.py b/src/navigate/config/config.py index 381c9c3c2..0e35c2318 100644 --- a/src/navigate/config/config.py +++ b/src/navigate/config/config.py @@ -208,8 +208,7 @@ def update_config_dict( config_name : str Name of dictionary to replace new_config : dict or str - Dictionary values or - yaml file name + Dictionary values or yaml file name Returns ------- @@ -751,16 +750,16 @@ def verify_waveform_constants(manager, configuration): # "delay", ]: if k not in waveform_dict[microscope_name][zoom][laser].keys(): - waveform_dict[microscope_name][zoom][laser][ - k - ] = config_dict["remote_focus"].get(k, "0") + waveform_dict[microscope_name][zoom][laser][k] = ( + config_dict["remote_focus"].get(k, "0") + ) else: try: float(waveform_dict[microscope_name][zoom][laser][k]) except ValueError: - waveform_dict[microscope_name][zoom][laser][ - k - ] = config_dict["remote_focus"].get(k, "0") + waveform_dict[microscope_name][zoom][laser][k] = ( + config_dict["remote_focus"].get(k, "0") + ) # delete non-exist lasers for k in waveform_dict[microscope_name][zoom].keys(): @@ -1004,24 +1003,16 @@ def verify_configuration(manager, configuration): laser_hardware_config = {"type": "Synthetic"} laser_hardware_config["wavelength"] = laser_config["wavelength"] - update_config_dict( - manager, - laser_config, - "hardware", - laser_hardware_config - ) + update_config_dict(manager, laser_config, "hardware", laser_hardware_config) # zoom zoom_config = device_config[microscope_name]["zoom"] if "hardware" not in zoom_config: update_config_dict( - manager, - zoom_config, - "hardware", - {"type": "Synthetic", "servo_id": 0} + manager, zoom_config, "hardware", {"type": "Synthetic", "servo_id": 0} ) elif "type" not in zoom_config["hardware"]: - zoom_config["hardware"]["type"] = "Synthetic" + zoom_config["hardware"]["type"] = "Synthetic" filter_wheel_config = device_config[microscope_name]["filter_wheel"] if type(filter_wheel_config) == DictProxy: @@ -1034,7 +1025,6 @@ def verify_configuration(manager, configuration): [filter_wheel_config], ) - temp_config = device_config[microscope_name]["filter_wheel"] for _, filter_wheel_config in enumerate(temp_config): filter_wheel_idx = build_ref_name( @@ -1091,7 +1081,7 @@ def verify_positions_config(positions): return [] position_num = len(positions) - for i in range(position_num - 1, start_index-1, -1): + for i in range(position_num - 1, start_index - 1, -1): position = positions[i] try: for j in range(len(position)): @@ -1101,6 +1091,7 @@ def verify_positions_config(positions): return positions + def support_deceased_configuration(configuration): """Support old version of configurations. @@ -1120,7 +1111,9 @@ def support_deceased_configuration(configuration): for microscope_name in device_config.keys(): microscope_config = device_config[microscope_name] if "remote_focus_device" in microscope_config.keys(): - microscope_config["remote_focus"] = microscope_config.pop("remote_focus_device") + microscope_config["remote_focus"] = microscope_config.pop( + "remote_focus_device" + ) is_updated = True if "lasers" in microscope_config.keys(): microscope_config["laser"] = microscope_config.pop("lasers") @@ -1136,4 +1129,3 @@ def support_deceased_configuration(configuration): if stage["type"] in deceased_device_type_names: stage["type"] = deceased_device_type_names[stage["type"]] is_updated = True - diff --git a/src/navigate/controller/sub_controllers/__init__.py b/src/navigate/controller/sub_controllers/__init__.py index 737ff0258..614ee3a09 100644 --- a/src/navigate/controller/sub_controllers/__init__.py +++ b/src/navigate/controller/sub_controllers/__init__.py @@ -19,7 +19,7 @@ from .microscope_popup import MicroscopePopupController # noqa from .adaptive_optics import AdaptiveOpticsPopupController # noqa from .histogram import HistogramController # noqa -from .stages_advanced import StageLimitsController # noqa +from .stages_advanced import AdvancedStageParametersController # noqa # from .uninstall_plugin_controller import UninstallPluginController # noqa from .plugins import PluginsController, UninstallPluginController # noqa diff --git a/src/navigate/controller/sub_controllers/menus.py b/src/navigate/controller/sub_controllers/menus.py index 779e99a5f..29a6d13a8 100644 --- a/src/navigate/controller/sub_controllers/menus.py +++ b/src/navigate/controller/sub_controllers/menus.py @@ -53,7 +53,7 @@ ) from navigate.view.popups.feature_list_popup import FeatureListPopup from navigate.view.popups.camera_setting_popup import CameraSettingPopup -from navigate.view.popups.stages_advanced_popup import StageLimitsPopup +from navigate.view.popups.stages_advanced_popup import AdvancedStageParametersPopup # Local Controller Imports from navigate.controller.sub_controllers.gui import GUIController @@ -68,7 +68,7 @@ FeatureAdvancedSettingController, AdaptiveOpticsPopupController, UninstallPluginController, - StageLimitsController, + AdvancedStageParametersController, ) # Local Tools Imports @@ -365,7 +365,7 @@ def initialize_menus(self): None, ], "add_separator_1": [None, None, None, None, None], - "Set Stage Limits": [ + "Advanced Stage Parameters": [ "standard", self.stage_limits_popup, None, @@ -1449,8 +1449,10 @@ def stage_limits_popup(self, *args, **kwargs) -> None: if hasattr(self.parent_controller, "stage_limits_popup_controller"): self.parent_controller.stage_limits_popup_controller.showup() return - popup = StageLimitsPopup(self.view) - stage_limits_controller = StageLimitsController(popup, self.parent_controller) + popup = AdvancedStageParametersPopup(self.view) + stage_limits_controller = AdvancedStageParametersController( + popup, self.parent_controller + ) setattr( self.parent_controller, "stage_limits_popup_controller", diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index 1f9c32553..882e4b9cd 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -28,20 +28,40 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from navigate.config.config import update_config_dict +# Standard Library Imports +import logging +import os +# Third Party Imports -class StageLimitsController: +# Local Imports +from navigate.config.config import update_config_dict, get_navigate_path +from navigate.tools.file_functions import save_yaml_file +from navigate.view.custom_widgets.popup import PopUp +from navigate.view.popups.stages_advanced_popup import AdvancedStageParametersPopup + +# Logger Setup +p = __name__.split(".")[1] +logger = logging.getLogger(p) + + +class AdvancedStageParametersController: """Controller for the Stage Limits popup.""" - def __init__(self, popup, parent_controller, *args, **kwargs): - """Initialize the StageLimitsController class. + def __init__( + self, + popup: AdvancedStageParametersPopup, + parent_controller: "Controller", + *args, + **kwargs, + ): + """Initialize the AdvancedStageParametersController class. Parameters ---------- root : tk.Tk The root window - popup : PopUp + popup : AdvancedStageParametersPopup The popup window for stage limits parent_controller : Controller The parent controller that manages this popup @@ -95,8 +115,31 @@ def __init__(self, popup, parent_controller, *args, **kwargs): # Checkbox trace for enabling/disabling stage limits. self.view.enable_stage_limits_var.trace_add("write", self.toggle_limits) + # Populate the list of microscopes in the dropdown. + self.view.microscope.set_values( + self.parent_controller.configuration_controller.microscope_list + ) + + #: str: The current microscope name. + self.current_microscope = ( + self.parent_controller.configuration_controller.microscope_name + ) + + # Set the current microscope in the dropdown. + self.view.microscope.set(self.current_microscope) + + # Add a trace to the microscope dropdown to detect microscope changes. + self.view.microscope.variable.trace_add("write", self.update_microscope) + + logger.debug("Stage limits popup initialized.") + def save_stage_limits(self): - print("Saving limits...") + file_directory = os.path.join(get_navigate_path(), "config") + save_yaml_file( + file_directory=file_directory, + content_dict=self.parent_controller.configuration["configuration"], + filename="configuration.yaml", + ) def toggle_limits(self, *args): """Toggle the stage limits on or off.""" @@ -135,7 +178,28 @@ def update_axis(self, axis): f"{axis}_{min_or_max}" ] = current_position[axis] - print(f"Updating {axis} {min_or_max} limits to {current_position[axis]}...") + # Get the current stage dictionary. Not sure if this is necessary. + stage_dict = dict( + self.parent_controller.configuration["configuration"]["microscopes"][ + self.current_microscope + ]["stage"] + ) + + # Update the stage dictionary with the new limit. + stage_dict[f"{axis}_{min_or_max}"] = current_position[axis] + + update_config_dict( + manager=self.parent_controller.manager, + parent_dict=self.parent_controller.configuration["configuration"][ + "microscopes" + ][self.current_microscope], + config_name="stage", + new_config=stage_dict, + ) + + logger.debug( + f"Updating {axis} {min_or_max} limits to" f" {current_position[axis]}..." + ) def close_popup(self): """Close the popup window.""" @@ -144,3 +208,10 @@ def close_popup(self): if hasattr(self.parent_controller, "stage_limits_popup_controller"): del self.parent_controller.stage_limits_popup_controller + + logger.debug("Stage limits popup closed and sub-controller deleted.") + + def update_microscope(self, *args): + """Update the microscope configuration when the microscope is changed.""" + self.current_microscope = self.view.microscope.get() + print(f"Updated microscope to {self.current_microscope}.") diff --git a/src/navigate/view/popups/stages_advanced_popup.py b/src/navigate/view/popups/stages_advanced_popup.py index 5bced29a6..c41460ad6 100644 --- a/src/navigate/view/popups/stages_advanced_popup.py +++ b/src/navigate/view/popups/stages_advanced_popup.py @@ -30,16 +30,18 @@ # Standard Library Imports import tkinter as tk +from tkinter import ttk -from navigate.view.custom_widgets.hover import HoverButton # Local Imports from navigate.view.custom_widgets.popup import PopUp -from navigate.view.custom_widgets.validation import ValidatedSpinbox +from navigate.view.custom_widgets.validation import ValidatedSpinbox, ValidatedCombobox +from navigate.view.custom_widgets.LabelInputWidgetFactory import LabelInput +from navigate.view.custom_widgets.hover import HoverButton -class StageLimitsPopup: - """Class creates the popup to set stage limits.""" +class AdvancedStageParametersPopup: + """Class creates the popup to set advanced stage parameters.""" def __init__(self, root, *args, **kwargs): """Initialize the CameraSettingPopup class. @@ -58,7 +60,7 @@ def __init__(self, root, *args, **kwargs): #: PopUp: Popup window for the camera view. self.popup = PopUp( root, - name="Stage Limits", + name="Advanced Stage Parameters", size="+320+180", top=False, transient=False, @@ -68,21 +70,6 @@ def __init__(self, root, *args, **kwargs): # Creating the frame for the popup self.frame = self.popup.content_frame - # Add tk labels to the frame - # Column 1, stage identity. Column 2, minimum limit. Column 3, update button. - # Column 4, maximum limit. Column 5, update button. - - # Create column headers - tk.Label(self.frame, text="Stage", font=("Arial", 10, "bold")).grid( - row=0, column=0, padx=5, pady=5, sticky="NSEW" - ) - tk.Label(self.frame, text="Minimum Limit", font=("Arial", 10, "bold")).grid( - row=0, column=1, columnspan=2, padx=5, pady=5, sticky="NSEW" - ) - tk.Label(self.frame, text="Maximum Limit", font=("Arial", 10, "bold")).grid( - row=0, column=3, columnspan=2, padx=5, pady=5, sticky="NSEW" - ) - #: dict: Dictionary to hold the buttons for updating limits. self.buttons = {} @@ -98,6 +85,9 @@ def __init__(self, root, *args, **kwargs): #: HoverButton: Button to save the limits. self.save_button = None + #: LabelInput: Dropdown for selecting the microscope. + self.microscope = None + def populate_view(self, stages, min, max): """Populate the view with the stages. @@ -119,25 +109,52 @@ def populate_view(self, stages, min, max): # Sort stages alphabetically sorted_stages = sorted(stages) + # Create a dropdown menu for selecting which microscope. + self.microscope = LabelInput( + self.frame, + label_pos="left", + label="Microscope", + input_class=ValidatedCombobox, + input_var=tk.StringVar(), + label_args={"font": ("Arial", 12, "bold")}, + input_args={ + "state": "readonly", + }, + ) + self.microscope.grid(row=0, column=0, columnspan=5, padx=5, pady=5, sticky="ew") + + # Create column headers + tk.Label(self.frame, text="Stage", font=("Arial", 10, "bold")).grid( + row=1, column=0, padx=5, pady=5, sticky="NSEW" + ) + tk.Label( + self.frame, text="Minimum Stage Limit", font=("Arial", 10, "bold") + ).grid(row=1, column=1, columnspan=2, padx=5, pady=5, sticky="NSEW") + tk.Label( + self.frame, text="Maximum Stage Limit", font=("Arial", 10, "bold") + ).grid(row=1, column=3, columnspan=2, padx=5, pady=5, sticky="NSEW") + # Create a row for each stage for i, stage_name in enumerate(sorted_stages, start=1): # Column 1: Stage name label tk.Label(self.frame, text=stage_name).grid( - row=i, column=0, padx=5, pady=2, sticky="w" + row=i + 2, column=0, padx=5, pady=2, sticky="w" ) # Column 2: Minimum limit spinbox self.spinboxes[stage_name + "_min"] = ValidatedSpinbox( self.frame, - from_=-10000, - to=10000, + from_=-100000, + to=100000, width=10, format="%.3f", increment=0.1, ) self.spinboxes[stage_name + "_min"].set(min.get(stage_name, 0.0)) - self.spinboxes[stage_name + "_min"].grid(row=i, column=1, padx=5, pady=2) + self.spinboxes[stage_name + "_min"].grid( + row=i + 2, column=1, padx=5, pady=2 + ) self.spinboxes[stage_name + "_min"].hover.setdescription( "The desired minimum limit for the stage." ) @@ -146,7 +163,7 @@ def populate_view(self, stages, min, max): self.buttons[stage_name + "_min"] = HoverButton( self.frame, text="Update", width=button_width ) - self.buttons[stage_name + "_min"].grid(row=i, column=2, padx=5, pady=2) + self.buttons[stage_name + "_min"].grid(row=i + 2, column=2, padx=5, pady=2) self.buttons[stage_name + "_min"].hover.setdescription( "Click to update the minimum limit for this stage to the current " "position." @@ -155,14 +172,16 @@ def populate_view(self, stages, min, max): # Column 4: Maximum limit spinbox self.spinboxes[stage_name + "_max"] = ValidatedSpinbox( self.frame, - from_=-10000, - to=10000, + from_=-100000, + to=100000, width=10, format="%.3f", increment=0.1, ) self.spinboxes[stage_name + "_max"].set(min.get(stage_name, 0.0)) - self.spinboxes[stage_name + "_max"].grid(row=i, column=3, padx=5, pady=2) + self.spinboxes[stage_name + "_max"].grid( + row=i + 2, column=3, padx=5, pady=2 + ) self.spinboxes[stage_name + "_max"].hover.setdescription( "The desired maximum limit for the stage." ) @@ -171,7 +190,7 @@ def populate_view(self, stages, min, max): self.buttons[stage_name + "_max"] = HoverButton( self.frame, text="Update", width=button_width ) - self.buttons[stage_name + "_max"].grid(row=i, column=4, padx=5, pady=2) + self.buttons[stage_name + "_max"].grid(row=i + 2, column=4, padx=5, pady=2) self.buttons[stage_name + "_max"].hover.setdescription( "Click to update the maximum limit for this stage to the current " "position." @@ -185,7 +204,7 @@ def populate_view(self, stages, min, max): variable=self.enable_stage_limits_var, ) self.stage_limits_enabled.grid( - row=len(sorted_stages) + 1, + row=len(sorted_stages) + 3, column=0, columnspan=2, padx=5, @@ -196,7 +215,7 @@ def populate_view(self, stages, min, max): # Save button. self.save_button = HoverButton(self.frame, text="Save", width=button_width) self.save_button.grid( - row=len(sorted_stages) + 1, + row=len(sorted_stages) + 3, column=4, columnspan=1, padx=5, From 922c70fb565762b9b4d4de476867da06053cb96c Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Mon, 21 Jul 2025 11:24:03 -0500 Subject: [PATCH 05/10] Add NI galvo stage support and flip flags to stage config Introduces detection of NI galvo (analog) stage in configuration and exposes it in the advanced stage parameters popup. Adds per-axis flip flags and offset controls to the advanced stage parameters UI, updates controller logic to handle these new parameters, and improves code clarity and typing. Also updates menu and main warning message for conciseness. --- .../controller/configuration_controller.py | 31 ++++- .../controller/sub_controllers/menus.py | 1 + .../sub_controllers/stages_advanced.py | 34 +++--- src/navigate/main.py | 4 +- .../view/popups/stages_advanced_popup.py | 106 ++++++++++++++++-- 5 files changed, 148 insertions(+), 28 deletions(-) diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index ce53b59ab..668eaa83a 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -255,7 +255,9 @@ def get_stage_position_limits(self, suffix): if self.microscope_config is not None: stage_dict = self.microscope_config["stage"] for a in axes: - position_limits[a] = stage_dict.get(a + suffix, 0 if suffix == "_min" else 100) + position_limits[a] = stage_dict.get( + a + suffix, 0 if suffix == "_min" else 100 + ) else: for a in axes: position_limits[a] = 0 if suffix == "_min" else 100 @@ -279,7 +281,7 @@ def stage_flip_flags(self): for axis in self.stage_axes: flip_flags[axis] = stage_dict.get(f"flip_{axis}", False) return flip_flags - + @property def stage_axes(self): """Return the axes of the stage @@ -298,9 +300,9 @@ def stage_axes(self): else: axes = list(stage_config["axes"]) return axes - + return ["x", "y"] - + @property def all_stage_axes(self): """Return all the axes of the stage @@ -411,6 +413,20 @@ def stage_setting_dict(self): return self.microscope_config["stage"] return None + @property + def has_analog_stage(self): + """Check to see if the has_ni_galvo_stage flag is set in the configuration. + + Returns + ------- + has_ni_galvo_stage : bool + True if the microscope has an NI galvo stage, False otherwise. + """ + + if self.microscope_config is not None: + return self.microscope_config["stage"].get("has_ni_galvo_stage", False) + return False + def get_stages_by_axis(self, axis_prefix="z"): """Return a list of all stage names. @@ -430,7 +446,12 @@ def get_stages_by_axis(self, axis_prefix="z"): stages = list(stages) else: stages = [stages] - return [f"{stage['type']} - {axis}" for stage in stages for axis in stage["axes"] if axis.startswith(axis_prefix)] + return [ + f"{stage['type']} - {axis}" + for stage in stages + for axis in stage["axes"] + if axis.startswith(axis_prefix) + ] return [] @property diff --git a/src/navigate/controller/sub_controllers/menus.py b/src/navigate/controller/sub_controllers/menus.py index 29a6d13a8..40e398b7c 100644 --- a/src/navigate/controller/sub_controllers/menus.py +++ b/src/navigate/controller/sub_controllers/menus.py @@ -372,6 +372,7 @@ def initialize_menus(self): None, None, ], + "add_separator_2": [None, None, None, None, None], }, } self.populate_menu(stage_control_menu) diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index 882e4b9cd..964766f8a 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -37,7 +37,6 @@ # Local Imports from navigate.config.config import update_config_dict, get_navigate_path from navigate.tools.file_functions import save_yaml_file -from navigate.view.custom_widgets.popup import PopUp from navigate.view.popups.stages_advanced_popup import AdvancedStageParametersPopup # Logger Setup @@ -54,7 +53,7 @@ def __init__( parent_controller: "Controller", *args, **kwargs, - ): + ) -> None: """Initialize the AdvancedStageParametersController class. Parameters @@ -77,26 +76,31 @@ def __init__( #: list: List of stages available in the system. self.num_stages = self.parent_controller.configuration_controller.all_stage_axes - #: dict: List of minimum limits for each stage. - self.min_limits = ( + min_limits = ( self.parent_controller.configuration_controller.get_stage_position_limits( suffix="_min" ) ) - #: dict: List of maximum limits for each stage. - self.max_limits = ( + max_limits = ( self.parent_controller.configuration_controller.get_stage_position_limits( suffix="_max" ) ) + current_flip_flags = ( + self.parent_controller.configuration_controller.stage_flip_flags + ) + + ni_stage = self.parent_controller.configuration_controller.has_analog_stage + #: PopUp: Popup window for the stage limits. self.view = popup # Initialize the view with the number of stages and their limits - self.view.populate_view(self.num_stages, self.min_limits, self.max_limits) - + self.view.populate_view( + self.num_stages, min_limits, max_limits, current_flip_flags, ni_stage + ) # Save button trace. self.view.save_button.configure(command=self.save_stage_limits) @@ -131,6 +135,8 @@ def __init__( # Add a trace to the microscope dropdown to detect microscope changes. self.view.microscope.variable.trace_add("write", self.update_microscope) + # Add a trace to the NI galvo stage variable. + logger.debug("Stage limits popup initialized.") def save_stage_limits(self): @@ -141,7 +147,7 @@ def save_stage_limits(self): filename="configuration.yaml", ) - def toggle_limits(self, *args): + def toggle_limits(self, *args) -> None: """Toggle the stage limits on or off.""" # Get the current state of the checkbox. @@ -156,7 +162,7 @@ def toggle_limits(self, *args): else: self.parent_controller.menu_controller.disable_stage_limits.set(1) - def update_axis(self, axis): + def update_axis(self, axis: str) -> None: """Get the current stage position, and update the stage limits in the configuration. axis: str @@ -178,7 +184,7 @@ def update_axis(self, axis): f"{axis}_{min_or_max}" ] = current_position[axis] - # Get the current stage dictionary. Not sure if this is necessary. + # Get the current stage dictionary. stage_dict = dict( self.parent_controller.configuration["configuration"]["microscopes"][ self.current_microscope @@ -201,7 +207,7 @@ def update_axis(self, axis): f"Updating {axis} {min_or_max} limits to" f" {current_position[axis]}..." ) - def close_popup(self): + def close_popup(self) -> None: """Close the popup window.""" self.save_stage_limits() self.view.popup.destroy() @@ -211,7 +217,9 @@ def close_popup(self): logger.debug("Stage limits popup closed and sub-controller deleted.") - def update_microscope(self, *args): + def update_microscope(self, *args) -> None: """Update the microscope configuration when the microscope is changed.""" self.current_microscope = self.view.microscope.get() print(f"Updated microscope to {self.current_microscope}.") + + # TODO: Repopulate the widgets with the new microscope configuration. diff --git a/src/navigate/main.py b/src/navigate/main.py index 73aa8b0dc..657158936 100644 --- a/src/navigate/main.py +++ b/src/navigate/main.py @@ -75,9 +75,7 @@ def main(): print( "WARNING: navigate was built to operate on a Windows platform. " "While much of the software will work for evaluation purposes, some " - "unanticipated behaviors may occur. For example, it is known that the " - "Tkinter-based GUI does not grid symmetrically, nor resize properly " - "on MacOS. Testing on Linux operating systems has not been performed." + "unanticipated behaviors may occur." ) # Start the GUI, withdraw main screen, and show splash screen. diff --git a/src/navigate/view/popups/stages_advanced_popup.py b/src/navigate/view/popups/stages_advanced_popup.py index c41460ad6..8a5e9cf20 100644 --- a/src/navigate/view/popups/stages_advanced_popup.py +++ b/src/navigate/view/popups/stages_advanced_popup.py @@ -37,7 +37,7 @@ from navigate.view.custom_widgets.popup import PopUp from navigate.view.custom_widgets.validation import ValidatedSpinbox, ValidatedCombobox from navigate.view.custom_widgets.LabelInputWidgetFactory import LabelInput -from navigate.view.custom_widgets.hover import HoverButton +from navigate.view.custom_widgets.hover import HoverButton, HoverCheckButton class AdvancedStageParametersPopup: @@ -76,19 +76,33 @@ def __init__(self, root, *args, **kwargs): #: dict: Dictionary to hold the spinboxes for stage limits. self.spinboxes = {} + #: dict: Dictionary to hold the flip flags for each stage. + self.flip_flags = {} + + #: dict: Dictionary to hold the flip buttons for each stage. + self.flip_button = {} + #: BooleanVar: Variable to hold the state of the stage limits checkbox. self.enable_stage_limits_var = None #: Checkbutton: Checkbox for the stage limits. self.stage_limits_enabled = None - #: HoverButton: Button to save the limits. + #: HoverCheckButton: Button to save the limits. self.save_button = None #: LabelInput: Dropdown for selecting the microscope. self.microscope = None - def populate_view(self, stages, min, max): + #: HoverCheckButton: Checkbutton for NI Galvo stage. + self.ni_galvo_stage = None + + #: BooleanVar: Variable to hold the state of the NI Galvo stage checkbox. + self.ni_galvo_flag = None + + def populate_view( + self, stages: list, min: dict, max: dict, flip_axes: dict, ni_stage: bool + ) -> None: """Populate the view with the stages. Add the widgets to the view for each stage in alphabetical order. @@ -103,6 +117,10 @@ def populate_view(self, stages, min, max): A dictionary containing the minimum limits for each stage. max : dict A dictionary containing the maximum limits for each stage. + flip_axes : dict + A dictionary containing the flip flags for each stage. + ni_stage : bool + A boolean indicating if the NI Galvo stage is being used. """ button_width = 6 @@ -121,19 +139,28 @@ def populate_view(self, stages, min, max): "state": "readonly", }, ) - self.microscope.grid(row=0, column=0, columnspan=5, padx=5, pady=5, sticky="ew") + self.microscope.grid(row=0, column=0, columnspan=7, padx=5, pady=5, sticky="ew") # Create column headers tk.Label(self.frame, text="Stage", font=("Arial", 10, "bold")).grid( row=1, column=0, padx=5, pady=5, sticky="NSEW" ) + tk.Label( self.frame, text="Minimum Stage Limit", font=("Arial", 10, "bold") ).grid(row=1, column=1, columnspan=2, padx=5, pady=5, sticky="NSEW") + tk.Label( self.frame, text="Maximum Stage Limit", font=("Arial", 10, "bold") ).grid(row=1, column=3, columnspan=2, padx=5, pady=5, sticky="NSEW") + tk.Label(self.frame, text="Stage Offsets", font=("Arial", 10, "bold")).grid( + row=1, column=5, columnspan=1, padx=5, pady=5, sticky="NSEW" + ) + + tk.Label(self.frame, text="Reverse Direction", font=("Arial", 10, "bold")).grid( + row=1, column=6, columnspan=1, padx=5, pady=5, sticky="NSEW" + ) # Create a row for each stage for i, stage_name in enumerate(sorted_stages, start=1): @@ -178,7 +205,7 @@ def populate_view(self, stages, min, max): format="%.3f", increment=0.1, ) - self.spinboxes[stage_name + "_max"].set(min.get(stage_name, 0.0)) + self.spinboxes[stage_name + "_max"].set(max.get(stage_name, 0.0)) self.spinboxes[stage_name + "_max"].grid( row=i + 2, column=3, padx=5, pady=2 ) @@ -196,9 +223,50 @@ def populate_view(self, stages, min, max): "position." ) + # Column 6: Offsets + self.spinboxes[stage_name + "_offset"] = ValidatedSpinbox( + self.frame, + from_=-100000, + to=100000, + width=10, + format="%.3f", + increment=0.1, + ) + self.spinboxes[stage_name + "_offset"].set(min.get(stage_name, 0.0)) + self.spinboxes[stage_name + "_offset"].grid( + row=i + 2, column=5, padx=5, pady=2 + ) + self.spinboxes[stage_name + "_offset"].hover.setdescription( + f"The relative offset between different microscope instances for the " + f"{stage_name} axis." + ) + + # Column 7: Flip flags. + self.flip_flags[stage_name] = tk.BooleanVar() + + self.flip_button[stage_name] = HoverCheckButton( + self.frame, + variable=self.flip_flags[stage_name], + ) + + self.flip_button[stage_name].grid( + row=i + 2, + column=6, + columnspan=1, + padx=5, + pady=5, + sticky="", + ) + self.flip_button[stage_name].hover.setdescription( + f"Reverse the direction of the stage movement for the {stage_name} " + "axis. " + ) + # Set the initial state of the flip flag. + self.flip_flags[stage_name].set(flip_axes.get(stage_name, False)) + # Provide a checkbox to disable the stage limits. self.enable_stage_limits_var = tk.BooleanVar() - self.stage_limits_enabled = tk.Checkbutton( + self.stage_limits_enabled = HoverCheckButton( self.frame, text="Stage Limits Enabled", variable=self.enable_stage_limits_var, @@ -211,12 +279,33 @@ def populate_view(self, stages, min, max): pady=5, sticky="w", ) + self.stage_limits_enabled.hover.setdescription( + "Enable or disable the stage limits. If disabled, the limits will not be " + "enforced." + ) + + # NI Galvo Flag + self.ni_galvo_flag = tk.BooleanVar() + self.ni_galvo_stage = HoverCheckButton( + self.frame, + text="Analog Stage", + variable=self.ni_galvo_flag, + ) + self.ni_galvo_stage.grid( + row=len(sorted_stages) + 3, + column=2, + columnspan=2, + padx=5, + pady=5, + sticky="w", + ) + self.ni_galvo_flag.set(ni_stage) # Save button. self.save_button = HoverButton(self.frame, text="Save", width=button_width) self.save_button.grid( row=len(sorted_stages) + 3, - column=4, + column=6, columnspan=1, padx=5, pady=5, @@ -225,3 +314,6 @@ def populate_view(self, stages, min, max): self.save_button.hover.setdescription( "Click to save the limits for all stages." ) + + # Center the flip flag checkboxes + self.frame.grid_columnconfigure(6, weight=1) From 73495ccb2271a1c9d758e845aa030004f73e59b7 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Thu, 24 Jul 2025 15:52:40 -0500 Subject: [PATCH 06/10] Refactor advanced stage parameters popup and controller Refactors the advanced stage parameters popup and its controller to improve microscope switching, configuration management, and widget handling. Removes NI Galvo stage logic from the popup, centralizes configuration updates, adds dynamic widget reconfiguration, and streamlines the saving and updating of stage parameters. Also adds debug print statements and improves code organization for maintainability. --- .../controller/configuration_controller.py | 13 +- .../sub_controllers/stages_advanced.py | 166 +++++++++++------- .../view/popups/stages_advanced_popup.py | 84 +++++---- 3 files changed, 151 insertions(+), 112 deletions(-) diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index 668eaa83a..40daa76b9 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -77,7 +77,7 @@ def __init__(self, configuration): ) ) - def change_microscope(self) -> bool: + def change_microscope(self, microscope_name=None) -> bool: """Get the new microscope configuration dict according to the name. Gets the name of the microscope, retrieves its configuration, and updates the @@ -87,9 +87,11 @@ def change_microscope(self) -> bool: ------- result: bool """ - microscope_name = self.configuration["experiment"]["MicroscopeState"][ - "microscope_name" - ] + if microscope_name is None: + microscope_name = self.configuration["experiment"]["MicroscopeState"][ + "microscope_name" + ] + assert ( microscope_name in self.configuration["configuration"]["microscopes"].keys() ) @@ -292,13 +294,16 @@ def stage_axes(self): List of axes, e.g. ['x', 'y', 'z', 'theta', 'f']. """ if self.microscope_config is not None: + print("Using microscope configuration") stage_config = self.microscope_config["stage"]["hardware"] axes = [] if isinstance(stage_config, ListProxy): for stage in stage_config: + print("Using stage configuration from ListProxy") axes.extend(list(stage["axes"])) else: axes = list(stage_config["axes"]) + print("Using stage configuration from DictProxy") return axes return ["x", "y"] diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index 964766f8a..d00a76279 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -38,6 +38,7 @@ from navigate.config.config import update_config_dict, get_navigate_path from navigate.tools.file_functions import save_yaml_file from navigate.view.popups.stages_advanced_popup import AdvancedStageParametersPopup +from navigate.controller.configuration_controller import ConfigurationController # Logger Setup p = __name__.split(".")[1] @@ -73,40 +74,40 @@ def __init__( # Initialize the parent controller self.parent_controller = parent_controller - #: list: List of stages available in the system. - self.num_stages = self.parent_controller.configuration_controller.all_stage_axes + #: PopUp: Popup window for the stage limits. + self.view = popup - min_limits = ( - self.parent_controller.configuration_controller.get_stage_position_limits( - suffix="_min" - ) + #: ConfigurationController: Controller for the local configuration. + self.local_config_controller = ConfigurationController( + self.parent_controller.configuration ) - max_limits = ( - self.parent_controller.configuration_controller.get_stage_position_limits( - suffix="_max" - ) - ) + # Populate the list of microscopes in the dropdown. + self.view.microscope.set_values(self.local_config_controller.microscope_list) + + #: str: The current microscope name. + self.current_microscope = self.local_config_controller.microscope_name - current_flip_flags = ( - self.parent_controller.configuration_controller.stage_flip_flags + # Set the current microscope in the dropdown. + self.view.microscope.set(self.current_microscope) + + #: dict: Stage configuration dictionary for the current microscope. + self.stage_dict = dict( + self.parent_controller.configuration["configuration"]["microscopes"][ + self.current_microscope + ]["stage"] ) - ni_stage = self.parent_controller.configuration_controller.has_analog_stage + self.update_microscope() - #: PopUp: Popup window for the stage limits. - self.view = popup + # Add a trace to the microscope dropdown to detect microscope changes. + self.view.microscope.variable.trace_add("write", self.update_microscope) - # Initialize the view with the number of stages and their limits - self.view.populate_view( - self.num_stages, min_limits, max_limits, current_flip_flags, ni_stage - ) # Save button trace. - self.view.save_button.configure(command=self.save_stage_limits) + self.view.save_button.configure(command=self.save_stage_parameters) - # Configure the spinboxes for each stage limit. - for key, value in self.view.buttons.items(): - value.configure(command=lambda k=key: self.update_axis(k)) + # Configure traces for the widgets in the popup. + self._configure_widget_traces() # Configure traces for closing the window or pressing escape. self.view.popup.protocol("WM_DELETE_WINDOW", self.close_popup) @@ -119,30 +120,20 @@ def __init__( # Checkbox trace for enabling/disabling stage limits. self.view.enable_stage_limits_var.trace_add("write", self.toggle_limits) - # Populate the list of microscopes in the dropdown. - self.view.microscope.set_values( - self.parent_controller.configuration_controller.microscope_list - ) + logger.debug("Stage limits popup initialized.") - #: str: The current microscope name. - self.current_microscope = ( - self.parent_controller.configuration_controller.microscope_name + def save_stage_parameters(self): + update_config_dict( + manager=self.parent_controller.manager, + parent_dict=self.parent_controller.configuration["configuration"][ + "microscopes" + ][self.current_microscope], + config_name="stage", + new_config=self.stage_dict, ) - # Set the current microscope in the dropdown. - self.view.microscope.set(self.current_microscope) - - # Add a trace to the microscope dropdown to detect microscope changes. - self.view.microscope.variable.trace_add("write", self.update_microscope) - - # Add a trace to the NI galvo stage variable. - - logger.debug("Stage limits popup initialized.") - - def save_stage_limits(self): - file_directory = os.path.join(get_navigate_path(), "config") save_yaml_file( - file_directory=file_directory, + file_directory=os.path.join(get_navigate_path(), "config"), content_dict=self.parent_controller.configuration["configuration"], filename="configuration.yaml", ) @@ -162,13 +153,27 @@ def toggle_limits(self, *args) -> None: else: self.parent_controller.menu_controller.disable_stage_limits.set(1) + def flip_axis(self, axis: str) -> None: + """Flip the stage axis in the configuration. + + Parameters + ---------- + axis : str + The axis to flip, e.g., 'x', 'y', or 'z'. + """ + # Update the parent controller's configuration controller with the new flip flag. + self.parent_controller.configuration_controller.microscope_config["stage"][ + f"flip_{axis}" + ] = self.view.flip_flags[axis].get() + + self.stage_dict[f"flip_{axis}"] = self.view.flip_flags[axis].get() + def update_axis(self, axis: str) -> None: """Get the current stage position, and update the stage limits in the configuration. axis: str The stage limit to update, e.g., 'x_min', 'y_max', etc. """ - # Identify the axis and whether it's a minimum or maximum limit. axis, min_or_max = axis.split("_") @@ -179,29 +184,19 @@ def update_axis(self, axis: str) -> None: # Update the popup window. self.view.spinboxes[f"{axis}_{min_or_max}"].set(current_position[axis]) - # Update the configuration. + # Update the parent controller's configuration controller. self.parent_controller.configuration_controller.microscope_config["stage"][ f"{axis}_{min_or_max}" ] = current_position[axis] - # Get the current stage dictionary. - stage_dict = dict( - self.parent_controller.configuration["configuration"]["microscopes"][ - self.current_microscope - ]["stage"] + print( + self.parent_controller.configuration_controller.microscope_config["stage"][ + f"{axis}_{min_or_max}" + ] ) - # Update the stage dictionary with the new limit. - stage_dict[f"{axis}_{min_or_max}"] = current_position[axis] - - update_config_dict( - manager=self.parent_controller.manager, - parent_dict=self.parent_controller.configuration["configuration"][ - "microscopes" - ][self.current_microscope], - config_name="stage", - new_config=stage_dict, - ) + # Update the stage dictionary with the new limit and/or flip flag. + self.stage_dict[f"{axis}_{min_or_max}"] = current_position[axis] logger.debug( f"Updating {axis} {min_or_max} limits to" f" {current_position[axis]}..." @@ -209,7 +204,8 @@ def update_axis(self, axis: str) -> None: def close_popup(self) -> None: """Close the popup window.""" - self.save_stage_limits() + print("Closing stage limits popup...") + self.save_stage_parameters() self.view.popup.destroy() if hasattr(self.parent_controller, "stage_limits_popup_controller"): @@ -219,7 +215,47 @@ def close_popup(self) -> None: def update_microscope(self, *args) -> None: """Update the microscope configuration when the microscope is changed.""" + + # Save the configuration for the previous microscope before switching. + self.save_stage_parameters() + + # Get the current microscope from the dropdown. self.current_microscope = self.view.microscope.get() - print(f"Updated microscope to {self.current_microscope}.") + self.view.clear_view() + + # Update the local configuration controller with the new microscope. + self.local_config_controller.microscope_name = self.current_microscope + + # Make sure the microscope_config. + self.local_config_controller.change_microscope(self.current_microscope) + + # Set the number of stage axes for the most recently selected microscope. + num_stages = self.local_config_controller.stage_axes + + # Get the minimum and maximum limits for each stage axis. + min_limits = self.local_config_controller.get_stage_position_limits( + suffix="_min" + ) + + max_limits = self.local_config_controller.get_stage_position_limits( + suffix="_max" + ) + + # Get the current flip flags for each stage axis. + current_flip_flags = self.local_config_controller.stage_flip_flags + + # Initialize the view with the number of stages and their limits + self.view.populate_view(num_stages, min_limits, max_limits, current_flip_flags) + + # Reconfigure traces for the new widgets + self._configure_widget_traces() + + def _configure_widget_traces(self): + """Configure traces and commands for widgets after they're created.""" + # Configure the spinboxes for each stage limit. + for key, value in self.view.buttons.items(): + value.configure(command=lambda k=key: self.update_axis(k)) - # TODO: Repopulate the widgets with the new microscope configuration. + # Configure the reverse flags for each stage axis. + for key, value in self.view.flip_flags.items(): + value.trace_add("write", lambda *args, k=key: self.flip_axis(k)) diff --git a/src/navigate/view/popups/stages_advanced_popup.py b/src/navigate/view/popups/stages_advanced_popup.py index 8a5e9cf20..7d5bda1b5 100644 --- a/src/navigate/view/popups/stages_advanced_popup.py +++ b/src/navigate/view/popups/stages_advanced_popup.py @@ -55,8 +55,6 @@ def __init__(self, root, *args, **kwargs): kwargs : dict Dictionary of keyword arguments. """ - # Creating popup window with this name and size/placement, PopUp is a - # Toplevel window #: PopUp: Popup window for the camera view. self.popup = PopUp( root, @@ -92,16 +90,21 @@ def __init__(self, root, *args, **kwargs): self.save_button = None #: LabelInput: Dropdown for selecting the microscope. - self.microscope = None - - #: HoverCheckButton: Checkbutton for NI Galvo stage. - self.ni_galvo_stage = None - - #: BooleanVar: Variable to hold the state of the NI Galvo stage checkbox. - self.ni_galvo_flag = None + self.microscope = LabelInput( + self.frame, + label_pos="left", + label="Microscope", + input_class=ValidatedCombobox, + input_var=tk.StringVar(), + label_args={"font": ("Arial", 12, "bold")}, + input_args={ + "state": "readonly", + }, + ) + self.microscope.grid(row=0, column=0, columnspan=7, padx=5, pady=5, sticky="ew") def populate_view( - self, stages: list, min: dict, max: dict, flip_axes: dict, ni_stage: bool + self, stages: list, min: dict, max: dict, flip_axes: dict ) -> None: """Populate the view with the stages. @@ -119,28 +122,12 @@ def populate_view( A dictionary containing the maximum limits for each stage. flip_axes : dict A dictionary containing the flip flags for each stage. - ni_stage : bool - A boolean indicating if the NI Galvo stage is being used. """ button_width = 6 # Sort stages alphabetically sorted_stages = sorted(stages) - # Create a dropdown menu for selecting which microscope. - self.microscope = LabelInput( - self.frame, - label_pos="left", - label="Microscope", - input_class=ValidatedCombobox, - input_var=tk.StringVar(), - label_args={"font": ("Arial", 12, "bold")}, - input_args={ - "state": "readonly", - }, - ) - self.microscope.grid(row=0, column=0, columnspan=7, padx=5, pady=5, sticky="ew") - # Create column headers tk.Label(self.frame, text="Stage", font=("Arial", 10, "bold")).grid( row=1, column=0, padx=5, pady=5, sticky="NSEW" @@ -284,23 +271,6 @@ def populate_view( "enforced." ) - # NI Galvo Flag - self.ni_galvo_flag = tk.BooleanVar() - self.ni_galvo_stage = HoverCheckButton( - self.frame, - text="Analog Stage", - variable=self.ni_galvo_flag, - ) - self.ni_galvo_stage.grid( - row=len(sorted_stages) + 3, - column=2, - columnspan=2, - padx=5, - pady=5, - sticky="w", - ) - self.ni_galvo_flag.set(ni_stage) - # Save button. self.save_button = HoverButton(self.frame, text="Save", width=button_width) self.save_button.grid( @@ -317,3 +287,31 @@ def populate_view( # Center the flip flag checkboxes self.frame.grid_columnconfigure(6, weight=1) + + def clear_view(self) -> None: + """Clear the view by destroying all widgets and resetting variables.""" + for widget_type in [self.spinboxes, self.buttons, self.flip_button]: + for widget in widget_type.values(): + widget.destroy() + widget_type.clear() + self.flip_flags.clear() + + for widget_type in [ + self.stage_limits_enabled, + self.save_button, + ]: + if widget_type is not None: + widget_type.destroy() + + # Clear all remaining widgets except the microscope dropdown + # This removes stage labels and headers that aren't stored in dictionaries + for widget in self.frame.winfo_children(): + grid_info = widget.grid_info() + if grid_info and widget != self.microscope: + # Keep row 0 (microscope dropdown), clear everything else + if int(grid_info.get("row", 0)) > 0: + widget.destroy() + + # Reset the widget variables to None + self.stage_limits_enabled = None + self.save_button = None From b06905a221b7ce1684c33e8f377f3c04a9c5cc84 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Fri, 25 Jul 2025 10:08:33 -0500 Subject: [PATCH 07/10] Finally... Improves synchronization between UI and configuration for advanced stage parameters, ensuring changes are saved and reflected immediately. Adds update_configuration method, refactors saving and updating logic, and enhances widget trace setup. Also updates popup UI for better clarity and usability. --- .../controller/configuration_controller.py | 10 +- .../controller/sub_controllers/stages.py | 16 ++- .../sub_controllers/stages_advanced.py | 115 ++++++++++++------ .../view/popups/stages_advanced_popup.py | 24 ++-- 4 files changed, 107 insertions(+), 58 deletions(-) diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index 40daa76b9..756d03a1e 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -77,6 +77,13 @@ def __init__(self, configuration): ) ) + def update_configuration(self) -> None: + """Update the microscope configuration to reflect any changes made to it.""" + + self.microscope_config = self.configuration["configuration"]["microscopes"][ + self.microscope_name + ] + def change_microscope(self, microscope_name=None) -> bool: """Get the new microscope configuration dict according to the name. @@ -294,16 +301,13 @@ def stage_axes(self): List of axes, e.g. ['x', 'y', 'z', 'theta', 'f']. """ if self.microscope_config is not None: - print("Using microscope configuration") stage_config = self.microscope_config["stage"]["hardware"] axes = [] if isinstance(stage_config, ListProxy): for stage in stage_config: - print("Using stage configuration from ListProxy") axes.extend(list(stage["axes"])) else: axes = list(stage_config["axes"]) - print("Using stage configuration from DictProxy") return axes return ["x", "y"] diff --git a/src/navigate/controller/sub_controllers/stages.py b/src/navigate/controller/sub_controllers/stages.py index 025afafba..95a50c263 100644 --- a/src/navigate/controller/sub_controllers/stages.py +++ b/src/navigate/controller/sub_controllers/stages.py @@ -179,8 +179,6 @@ def __init__( command=self.parent_controller.channels_tab_controller.update_end_position ) - - def stage_key_press(self, event: tk.Event) -> None: """The stage key press. @@ -210,6 +208,7 @@ def initialize(self) -> None: """Initialize the Stage limits of steps and positions.""" config = self.parent_controller.configuration_controller self.stage_axes = config.stage_axes + # disable stages not available for the current microscope for axis in set(config.all_stage_axes) - set(self.stage_axes): stage_frame = getattr(self.view, f"{axis}_frame") @@ -310,7 +309,7 @@ def disable_synthetic_stages(self, config: ConfigurationController) -> None: elif axis == "y": self.view.xy_frame.toggle_button_states(flag, ["y"]) self.view.position_frame.inputs["y"].widget.config(state=state) - + else: stage_frame = getattr(self.view, f"{axis}_frame") stage_frame.toggle_button_states(flag, [axis]) @@ -621,6 +620,7 @@ def update_step_size_handler(self, axis: str) -> Callable[[], None]: handler : Callable[[], None] Function to update step size in experiment.yml. """ + def func(*args): """Callback functions bind to step size variables.""" microscope_name = self.parent_controller.configuration["experiment"][ @@ -659,15 +659,19 @@ def set_hover_descriptions(self) -> None: description = f"\N{GREEK SMALL LETTER MU}m in {axis.upper()}." for i in range(len(btn_prefix)): - exec(f"self.view.{frame_prefix}_frame.{btn_prefix[i]}_{btn_suffix}.hover." - f"setdescription('Move {step_multiple[i] * step_value} {description}')") + exec( + f"self.view.{frame_prefix}_frame.{btn_prefix[i]}_{btn_suffix}.hover." + f"setdescription('Move {step_multiple[i] * step_value} {description}')" + ) # Position Frame for axis in self.stage_axes: if axis == "theta": description = "Theta stage position in degrees." else: - description = f"{axis.upper()} stage position in \N{GREEK SMALL LETTER MU}m." + description = ( + f"{axis.upper()} stage position in \N{GREEK SMALL LETTER MU}m." + ) self.view.position_frame.inputs[axis].widget.hover.setdescription( description ) diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index d00a76279..77efefc57 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -92,20 +92,13 @@ def __init__( self.view.microscope.set(self.current_microscope) #: dict: Stage configuration dictionary for the current microscope. - self.stage_dict = dict( - self.parent_controller.configuration["configuration"]["microscopes"][ - self.current_microscope - ]["stage"] - ) + self.stage_dict = self.local_config_controller.microscope_config["stage"] - self.update_microscope() + self.update_microscope(in_initialization=True) # Add a trace to the microscope dropdown to detect microscope changes. self.view.microscope.variable.trace_add("write", self.update_microscope) - # Save button trace. - self.view.save_button.configure(command=self.save_stage_parameters) - # Configure traces for the widgets in the popup. self._configure_widget_traces() @@ -113,16 +106,12 @@ def __init__( self.view.popup.protocol("WM_DELETE_WINDOW", self.close_popup) self.view.popup.bind("", lambda event: self.close_popup()) - # See if the stage limits are currently enabled or disabled. - self.stage_limits_enabled = self.parent_controller.stage_controller.stage_limits - self.view.enable_stage_limits_var.set(self.stage_limits_enabled) - - # Checkbox trace for enabling/disabling stage limits. - self.view.enable_stage_limits_var.trace_add("write", self.toggle_limits) - logger.debug("Stage limits popup initialized.") - def save_stage_parameters(self): + def save_stage_parameters(self) -> None: + """Save the current stage parameters to the configuration file.""" + + # Update the stage dictionary. update_config_dict( manager=self.parent_controller.manager, parent_dict=self.parent_controller.configuration["configuration"][ @@ -132,12 +121,19 @@ def save_stage_parameters(self): new_config=self.stage_dict, ) + # Save the updated configuration to a YAML file. save_yaml_file( file_directory=os.path.join(get_navigate_path(), "config"), content_dict=self.parent_controller.configuration["configuration"], filename="configuration.yaml", ) + # Update the configuration controller with the new configuration. + self.parent_controller.configuration_controller.update_configuration() + + # Reinitialize the stage controller with the new configuration. + self.parent_controller.stage_controller.initialize() + def toggle_limits(self, *args) -> None: """Toggle the stage limits on or off.""" @@ -161,15 +157,20 @@ def flip_axis(self, axis: str) -> None: axis : str The axis to flip, e.g., 'x', 'y', or 'z'. """ - # Update the parent controller's configuration controller with the new flip flag. - self.parent_controller.configuration_controller.microscope_config["stage"][ - f"flip_{axis}" - ] = self.view.flip_flags[axis].get() + # Update the loaded configuration. + self.parent_controller.configuration["configuration"]["microscopes"][ + self.current_microscope + ]["stage"][f"flip_{axis}"] = self.view.flip_flags[axis].get() + # Update our local stage dictionary with the new flip flag. self.stage_dict[f"flip_{axis}"] = self.view.flip_flags[axis].get() + logger.debug( + f"Updating {axis} flip flag to {self.stage_dict[f'flip_{axis}']}..." + ) def update_axis(self, axis: str) -> None: - """Get the current stage position, and update the stage limits in the configuration. + """Get the current stage position, and update the stage limits in the + configuration. Only applied when someone presses the "update" button. axis: str The stage limit to update, e.g., 'x_min', 'y_max', etc. @@ -184,16 +185,10 @@ def update_axis(self, axis: str) -> None: # Update the popup window. self.view.spinboxes[f"{axis}_{min_or_max}"].set(current_position[axis]) - # Update the parent controller's configuration controller. - self.parent_controller.configuration_controller.microscope_config["stage"][ - f"{axis}_{min_or_max}" - ] = current_position[axis] - - print( - self.parent_controller.configuration_controller.microscope_config["stage"][ - f"{axis}_{min_or_max}" - ] - ) + # Update the loaded configuration. + self.parent_controller.configuration["configuration"]["microscopes"][ + self.current_microscope + ]["stage"][f"{axis}_{min_or_max}"] = current_position[axis] # Update the stage dictionary with the new limit and/or flip flag. self.stage_dict[f"{axis}_{min_or_max}"] = current_position[axis] @@ -204,7 +199,6 @@ def update_axis(self, axis: str) -> None: def close_popup(self) -> None: """Close the popup window.""" - print("Closing stage limits popup...") self.save_stage_parameters() self.view.popup.destroy() @@ -213,21 +207,27 @@ def close_popup(self) -> None: logger.debug("Stage limits popup closed and sub-controller deleted.") - def update_microscope(self, *args) -> None: - """Update the microscope configuration when the microscope is changed.""" + def update_microscope(self, *args, in_initialization=False) -> None: + """Update the microscope configuration when the microscope is changed. + Parameters + ---------- + in_initialization : bool, optional + If True, this method is called during initialization and does not + save the previous stage parameters. + """ # Save the configuration for the previous microscope before switching. - self.save_stage_parameters() + if not in_initialization: + self.save_stage_parameters() # Get the current microscope from the dropdown. self.current_microscope = self.view.microscope.get() self.view.clear_view() # Update the local configuration controller with the new microscope. - self.local_config_controller.microscope_name = self.current_microscope - - # Make sure the microscope_config. - self.local_config_controller.change_microscope(self.current_microscope) + self.local_config_controller.change_microscope( + microscope_name=self.current_microscope + ) # Set the number of stage axes for the most recently selected microscope. num_stages = self.local_config_controller.stage_axes @@ -250,6 +250,27 @@ def update_microscope(self, *args) -> None: # Reconfigure traces for the new widgets self._configure_widget_traces() + def update_spinboxes(self, axis) -> None: + """Update the spinboxes for the stage limits. + + Parameters + ---------- + axis : str + The axis to update, e.g., 'x', 'y', or 'z'. + """ + # Get the current value from the spinbox. + value = int(self.view.spinboxes[axis].get()) + + # Update the loaded configuration. + self.parent_controller.configuration["configuration"]["microscopes"][ + self.current_microscope + ]["stage"][axis] = value + + # Update our local stage dictionary with the new value. + self.stage_dict[axis] = value + + logger.debug(f"Updating {axis} limit to {value}...") + def _configure_widget_traces(self): """Configure traces and commands for widgets after they're created.""" # Configure the spinboxes for each stage limit. @@ -259,3 +280,17 @@ def _configure_widget_traces(self): # Configure the reverse flags for each stage axis. for key, value in self.view.flip_flags.items(): value.trace_add("write", lambda *args, k=key: self.flip_axis(k)) + + # Configure trace for minimum limit, maximum limit, and offset spinboxes. + for key, value in self.view.spinboxes.items(): + value.bind("", lambda event, k=key: self.update_spinboxes(k)) + + # Save button trace. + self.view.save_button.configure(command=self.save_stage_parameters) + + # See if the stage limits are currently enabled or disabled. + self.stage_limits_enabled = self.parent_controller.stage_controller.stage_limits + self.view.enable_stage_limits_var.set(self.stage_limits_enabled) + + # Checkbox trace for enabling/disabling stage limits. + self.view.enable_stage_limits_var.trace_add("write", self.toggle_limits) diff --git a/src/navigate/view/popups/stages_advanced_popup.py b/src/navigate/view/popups/stages_advanced_popup.py index 7d5bda1b5..00df5fdcb 100644 --- a/src/navigate/view/popups/stages_advanced_popup.py +++ b/src/navigate/view/popups/stages_advanced_popup.py @@ -96,7 +96,7 @@ def __init__(self, root, *args, **kwargs): label="Microscope", input_class=ValidatedCombobox, input_var=tk.StringVar(), - label_args={"font": ("Arial", 12, "bold")}, + label_args={"font": ("Arial", 14, "bold")}, input_args={ "state": "readonly", }, @@ -151,9 +151,12 @@ def populate_view( # Create a row for each stage for i, stage_name in enumerate(sorted_stages, start=1): + # Capitalize the first letter of the stage name + display_name = stage_name.capitalize() + # Column 1: Stage name label - tk.Label(self.frame, text=stage_name).grid( - row=i + 2, column=0, padx=5, pady=2, sticky="w" + tk.Label(self.frame, text=display_name, font=("Arial", 10, "bold")).grid( + row=i + 2, column=0, padx=5, pady=2, sticky="ew" ) # Column 2: Minimum limit spinbox @@ -162,8 +165,8 @@ def populate_view( from_=-100000, to=100000, width=10, - format="%.3f", - increment=0.1, + format="%.0f", + increment=1, ) self.spinboxes[stage_name + "_min"].set(min.get(stage_name, 0.0)) self.spinboxes[stage_name + "_min"].grid( @@ -189,8 +192,8 @@ def populate_view( from_=-100000, to=100000, width=10, - format="%.3f", - increment=0.1, + format="%.0f", + increment=1, ) self.spinboxes[stage_name + "_max"].set(max.get(stage_name, 0.0)) self.spinboxes[stage_name + "_max"].grid( @@ -216,8 +219,8 @@ def populate_view( from_=-100000, to=100000, width=10, - format="%.3f", - increment=0.1, + format="%.0f", + increment=1, ) self.spinboxes[stage_name + "_offset"].set(min.get(stage_name, 0.0)) self.spinboxes[stage_name + "_offset"].grid( @@ -252,11 +255,14 @@ def populate_view( self.flip_flags[stage_name].set(flip_axes.get(stage_name, False)) # Provide a checkbox to disable the stage limits. + style = ttk.Style() + style.configure("Custom.TCheckbutton", font=("Arial", 10, "bold")) self.enable_stage_limits_var = tk.BooleanVar() self.stage_limits_enabled = HoverCheckButton( self.frame, text="Stage Limits Enabled", variable=self.enable_stage_limits_var, + style="Custom.TCheckbutton", ) self.stage_limits_enabled.grid( row=len(sorted_stages) + 3, From d9c0ed43ea8c0715cea89ba586b5712ab48f0b05 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Fri, 25 Jul 2025 10:58:33 -0500 Subject: [PATCH 08/10] Add stage offsets to advanced stage parameters Introduces support for stage offsets in the configuration controller and advanced stage parameters popup. Updates type hints and refactors related methods to handle offsets, ensuring the view and controller can display and update stage offset values alongside limits and flip flags. --- .../controller/configuration_controller.py | 84 ++++++++++++------- .../sub_controllers/stages_advanced.py | 23 +++-- .../view/popups/stages_advanced_popup.py | 24 ++++-- 3 files changed, 86 insertions(+), 45 deletions(-) diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index 756d03a1e..53b966d79 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -34,6 +34,7 @@ # Standard Library Imports import logging from multiprocessing.managers import ListProxy, DictProxy +from typing import Optional # Third Party Imports @@ -47,15 +48,15 @@ class ConfigurationController: """Configuration Controller - Used to get the configuration of the microscope.""" - def __init__(self, configuration): + def __init__(self, configuration: DictProxy) -> None: """Initialize the Configuration Controller Parameters ---------- - configuration : dict + configuration : DictProxy The configuration dictionary. """ - #: dict: The configuration dictionary. + #: DictProxy: The configuration dictionary. self.configuration = configuration #: str: The microscope name. @@ -112,7 +113,7 @@ def change_microscope(self, microscope_name=None) -> bool: self.microscope_name = microscope_name return True - def get_microscope_configuration_dict(self): + def get_microscope_configuration_dict(self) -> dict: """Return microscope configuration dictionary Returns @@ -122,7 +123,7 @@ def get_microscope_configuration_dict(self): return self.microscope_config @property - def channels_info(self): + def channels_info(self) -> dict: """Return the channels info Populate the channel combobox with the channels @@ -148,7 +149,7 @@ def channels_info(self): return setting @property - def lasers_info(self): + def lasers_info(self) -> list: """Return the lasers info Populate the laser combobox with the lasers @@ -167,7 +168,7 @@ def lasers_info(self): ] @property - def camera_config_dict(self): + def camera_config_dict(self) -> dict: """Get camera configuration dict Returns @@ -182,7 +183,7 @@ def camera_config_dict(self): return None @property - def camera_pixels(self): + def camera_pixels(self) -> list[int]: """Get default pixel values from camera Returns @@ -201,7 +202,7 @@ def camera_pixels(self): ] @property - def stage_default_position(self): + def stage_default_position(self) -> dict: """Get current position of the stage Returns @@ -223,7 +224,7 @@ def stage_default_position(self): return position @property - def stage_step(self): + def stage_step(self) -> dict: """Get the step size of the stage Returns @@ -244,7 +245,25 @@ def stage_step(self): steps = {"x": 10, "y": 10, "z": 10, "theta": 10, "f": 10} return steps - def get_stage_position_limits(self, suffix): + @property + def stage_offsets(self) -> dict: + """Get the offsets of the stage + + Returns + ------- + offsets : dict + Offsets in x, y, z, theta, and f. + """ + if self.microscope_config is not None: + stage_dict = self.microscope_config["stage"] + else: + stage_dict = {} + offsets = {} + for axis in self.stage_axes: + offsets[axis] = stage_dict.get(f"{axis}_offset", 0) + return offsets + + def get_stage_position_limits(self, suffix: str) -> dict: """Return the position limits of the stage Parameters @@ -273,7 +292,7 @@ def get_stage_position_limits(self, suffix): return position_limits @property - def stage_flip_flags(self): + def stage_flip_flags(self) -> dict[str, bool]: """Return the flip flags of the stage Returns @@ -292,7 +311,7 @@ def stage_flip_flags(self): return flip_flags @property - def stage_axes(self): + def stage_axes(self) -> list[str]: """Return the axes of the stage Returns @@ -313,7 +332,7 @@ def stage_axes(self): return ["x", "y"] @property - def all_stage_axes(self): + def all_stage_axes(self) -> list[str]: """Return all the axes of the stage Returns @@ -334,7 +353,7 @@ def all_stage_axes(self): return list(set(axes)) @property - def camera_flip_flags(self): + def camera_flip_flags(self) -> dict[str, bool]: """Return the flip flags of the camera Returns @@ -353,7 +372,7 @@ def camera_flip_flags(self): return flip_flags @property - def remote_focus_dict(self): + def remote_focus_dict(self) -> dict: """Return delay_percent, pulse_percent. Returns @@ -367,7 +386,7 @@ def remote_focus_dict(self): return None @property - def galvo_parameter_dict(self): + def galvo_parameter_dict(self) -> dict: """Return galvo parameter dict. Returns @@ -384,12 +403,12 @@ def galvo_parameter_dict(self): return None @property - def daq_sample_rate(self): + def daq_sample_rate(self) -> int: """Return daq sample rate. Returns ------- - daq_sample_rate : float + daq_sample_rate : int Sample rate of the daq. """ if self.microscope_config is not None: @@ -397,7 +416,7 @@ def daq_sample_rate(self): return 100000 @property - def filter_wheel_setting_dict(self): + def filter_wheel_setting_dict(self) -> dict: """Return filter wheel setting dict. Returns @@ -410,7 +429,7 @@ def filter_wheel_setting_dict(self): return None @property - def stage_setting_dict(self): + def stage_setting_dict(self) -> dict: """Return stage setting dict. Returns @@ -423,7 +442,7 @@ def stage_setting_dict(self): return None @property - def has_analog_stage(self): + def has_analog_stage(self) -> bool: """Check to see if the has_ni_galvo_stage flag is set in the configuration. Returns @@ -436,7 +455,7 @@ def has_analog_stage(self): return self.microscope_config["stage"].get("has_ni_galvo_stage", False) return False - def get_stages_by_axis(self, axis_prefix="z"): + def get_stages_by_axis(self, axis_prefix: Optional[str] = "z"): """Return a list of all stage names. Parameters @@ -464,7 +483,7 @@ def get_stages_by_axis(self, axis_prefix="z"): return [] @property - def number_of_channels(self): + def number_of_channels(self) -> int: """Return number of channels. Returns @@ -477,7 +496,7 @@ def number_of_channels(self): return 5 @property - def number_of_filter_wheels(self): + def number_of_filter_wheels(self) -> int: """Return number of filter wheels Returns @@ -491,7 +510,7 @@ def number_of_filter_wheels(self): return 1 @property - def filter_wheel_names(self): + def filter_wheel_names(self) -> list[str]: """Return a list of filter wheel names Returns @@ -509,7 +528,7 @@ def filter_wheel_names(self): return filter_wheel_names @property - def microscope_list(self): + def microscope_list(self) -> list[str]: """Return a list of microscope names Returns @@ -519,7 +538,7 @@ def microscope_list(self): """ return list(self.configuration["configuration"]["microscopes"].keys()) - def get_zoom_value_list(self, microscope_name): + def get_zoom_value_list(self, microscope_name: str) -> list: """Return a list of zoom values Returns @@ -532,5 +551,12 @@ def get_zoom_value_list(self, microscope_name): ].keys() @property - def gui_setting(self): + def gui_setting(self) -> dict: + """Return the GUI settings + + Returns + ------- + gui_setting : dict + Dictionary with the GUI settings. + """ return self.configuration["configuration"]["gui"] diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index 77efefc57..461492e83 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -31,6 +31,7 @@ # Standard Library Imports import logging import os +from typing import Optional # Third Party Imports @@ -46,7 +47,7 @@ class AdvancedStageParametersController: - """Controller for the Stage Limits popup.""" + """Controller for the Advanced Stage Parameters popup.""" def __init__( self, @@ -194,7 +195,7 @@ def update_axis(self, axis: str) -> None: self.stage_dict[f"{axis}_{min_or_max}"] = current_position[axis] logger.debug( - f"Updating {axis} {min_or_max} limits to" f" {current_position[axis]}..." + f"Updating {axis} {min_or_max} limits to {current_position[axis]}..." ) def close_popup(self) -> None: @@ -207,7 +208,9 @@ def close_popup(self) -> None: logger.debug("Stage limits popup closed and sub-controller deleted.") - def update_microscope(self, *args, in_initialization=False) -> None: + def update_microscope( + self, *args, in_initialization: Optional[bool] = False + ) -> None: """Update the microscope configuration when the microscope is changed. Parameters @@ -244,13 +247,18 @@ def update_microscope(self, *args, in_initialization=False) -> None: # Get the current flip flags for each stage axis. current_flip_flags = self.local_config_controller.stage_flip_flags - # Initialize the view with the number of stages and their limits - self.view.populate_view(num_stages, min_limits, max_limits, current_flip_flags) + # Get the current offsets for each stage axis. + offsets = self.local_config_controller.stage_offsets + + # Initialize the view with the number of stage_list and their limits + self.view.populate_view( + num_stages, min_limits, max_limits, current_flip_flags, offsets + ) # Reconfigure traces for the new widgets self._configure_widget_traces() - def update_spinboxes(self, axis) -> None: + def update_spinboxes(self, axis: str) -> None: """Update the spinboxes for the stage limits. Parameters @@ -268,10 +276,9 @@ def update_spinboxes(self, axis) -> None: # Update our local stage dictionary with the new value. self.stage_dict[axis] = value - logger.debug(f"Updating {axis} limit to {value}...") - def _configure_widget_traces(self): + def _configure_widget_traces(self) -> None: """Configure traces and commands for widgets after they're created.""" # Configure the spinboxes for each stage limit. for key, value in self.view.buttons.items(): diff --git a/src/navigate/view/popups/stages_advanced_popup.py b/src/navigate/view/popups/stages_advanced_popup.py index 00df5fdcb..8616e128e 100644 --- a/src/navigate/view/popups/stages_advanced_popup.py +++ b/src/navigate/view/popups/stages_advanced_popup.py @@ -104,24 +104,32 @@ def __init__(self, root, *args, **kwargs): self.microscope.grid(row=0, column=0, columnspan=7, padx=5, pady=5, sticky="ew") def populate_view( - self, stages: list, min: dict, max: dict, flip_axes: dict + self, + stages: list, + min_dict: dict, + max_dict: dict, + flip_axes: dict, + offsets: dict, ) -> None: """Populate the view with the stages. + Add the widgets to the view for each stage in alphabetical order. - Creates a row for each stage with: stage name, min limit spinbox, - update min button, max limit spinbox, and update max button. + Creates a row for each stage with: stage name, min_dict limit spinbox, + update min_dict button, max limit spinbox, and update max button. Parameters ---------- stages : list The list of stage names as strings. - min : dict + min_dict : dict A dictionary containing the minimum limits for each stage. - max : dict + max_dict : dict A dictionary containing the maximum limits for each stage. flip_axes : dict A dictionary containing the flip flags for each stage. + offsets : dict + A dictionary containing the offsets for each stage. """ button_width = 6 @@ -168,7 +176,7 @@ def populate_view( format="%.0f", increment=1, ) - self.spinboxes[stage_name + "_min"].set(min.get(stage_name, 0.0)) + self.spinboxes[stage_name + "_min"].set(min_dict.get(stage_name, 0.0)) self.spinboxes[stage_name + "_min"].grid( row=i + 2, column=1, padx=5, pady=2 ) @@ -195,7 +203,7 @@ def populate_view( format="%.0f", increment=1, ) - self.spinboxes[stage_name + "_max"].set(max.get(stage_name, 0.0)) + self.spinboxes[stage_name + "_max"].set(max_dict.get(stage_name, 0.0)) self.spinboxes[stage_name + "_max"].grid( row=i + 2, column=3, padx=5, pady=2 ) @@ -222,7 +230,7 @@ def populate_view( format="%.0f", increment=1, ) - self.spinboxes[stage_name + "_offset"].set(min.get(stage_name, 0.0)) + self.spinboxes[stage_name + "_offset"].set(offsets.get(stage_name, 0.0)) self.spinboxes[stage_name + "_offset"].grid( row=i + 2, column=5, padx=5, pady=2 ) From b6699bcf037cd12263a3b40c1a43f20dd5e334f8 Mon Sep 17 00:00:00 2001 From: Annie Wang Date: Wed, 30 Jul 2025 11:31:59 -0500 Subject: [PATCH 09/10] feat: add function to write to standard YAML file --- src/navigate/tools/file_functions.py | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/navigate/tools/file_functions.py b/src/navigate/tools/file_functions.py index 3433c3f06..d5ca959b8 100644 --- a/src/navigate/tools/file_functions.py +++ b/src/navigate/tools/file_functions.py @@ -38,6 +38,7 @@ from pathlib import Path import psutil from typing import Union +from multiprocessing.managers import ListProxy, DictProxy # Third party imports @@ -197,6 +198,45 @@ def save_yaml_file( return False return True +def write_to_yaml(content_dict: dict, filename: str) -> None: + """write to a standard yaml file + + Parameters + ---------- + content_dict: dict + configuration dictionary + filename: str + yaml file name + """ + + def write_func(prefix, config_dict, f): + for k in config_dict.keys(): + if type(config_dict[k])in [dict, DictProxy]: + f.write(f"{prefix}{k}:\n") + write_func(prefix + " " * 2, config_dict[k], f) + elif type(config_dict[k]) in [list, ListProxy]: + # it the list is a simple list, we can write it out + is_simple_list = True + for item in config_dict[k]: + if type(item) in [dict, DictProxy, list, ListProxy]: + is_simple_list = False + break + if is_simple_list: + f.write(f"{prefix}{k}: {config_dict[k]}\n") + continue + list_prefix = " " + if k != "None": + f.write(f"{prefix}{k}:\n") + list_prefix = " " * 2 + for list_item in config_dict[k]: + f.write(f"{prefix}{list_prefix}-\n") + write_func(prefix + list_prefix * 2, list_item, f) + elif k != "": + f.write(f"{prefix}{k}: {config_dict[k]}\n") + + with open(filename, "w") as f: + write_func("", content_dict, f) + def delete_folder(top: str) -> None: """Delete folder and all sub-folders. From 18616f35d413faeabdd1566052c3bacff833e898 Mon Sep 17 00:00:00 2001 From: Annie Wang Date: Wed, 30 Jul 2025 11:32:28 -0500 Subject: [PATCH 10/10] save configuration in standard yaml format --- src/navigate/controller/sub_controllers/stages_advanced.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/navigate/controller/sub_controllers/stages_advanced.py b/src/navigate/controller/sub_controllers/stages_advanced.py index 461492e83..6b0db9cd2 100644 --- a/src/navigate/controller/sub_controllers/stages_advanced.py +++ b/src/navigate/controller/sub_controllers/stages_advanced.py @@ -37,7 +37,7 @@ # Local Imports from navigate.config.config import update_config_dict, get_navigate_path -from navigate.tools.file_functions import save_yaml_file +from navigate.tools.file_functions import write_to_yaml from navigate.view.popups.stages_advanced_popup import AdvancedStageParametersPopup from navigate.controller.configuration_controller import ConfigurationController @@ -123,10 +123,9 @@ def save_stage_parameters(self) -> None: ) # Save the updated configuration to a YAML file. - save_yaml_file( - file_directory=os.path.join(get_navigate_path(), "config"), + write_to_yaml( content_dict=self.parent_controller.configuration["configuration"], - filename="configuration.yaml", + filename=os.path.join(get_navigate_path(), "config", "configuration.yaml"), ) # Update the configuration controller with the new configuration.