Skip to content

Commit 2c68f92

Browse files
committed
refactor(parameter editor): Move more business logic out of the GUI
Make some functions protected to minimize the public API
1 parent aa7a40b commit 2c68f92

6 files changed

Lines changed: 264 additions & 246 deletions

ardupilot_methodic_configurator/configuration_manager.py

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from logging import error as logging_error
1717
from logging import info as logging_info
1818
from pathlib import Path
19-
from typing import Callable, Optional
19+
from time import time
20+
from typing import Callable, Literal, Optional
2021
from webbrowser import open as webbrowser_open # to open the web documentation
2122

2223
from ardupilot_methodic_configurator import _
@@ -36,6 +37,8 @@
3637
ShowErrorCallback = Callable[[str, str], None] # (title, message) -> None
3738
ShowInfoCallback = Callable[[str, str], None] # (title, message) -> None
3839
AskRetryCancelCallback = Callable[[str, str], bool] # (title, message) -> bool
40+
ExperimentChoice = Literal["close", True, False]
41+
ExperimentChoiceCallback = Callable[[str, str, list[str]], ExperimentChoice]
3942

4043

4144
class OperationNotPossibleError(Exception):
@@ -76,6 +79,8 @@ def __init__(self, current_file: str, flight_controller: FlightController, files
7679

7780
self._at_least_one_changed = False
7881

82+
self._last_time_asked_to_save: float = 0
83+
7984
# frontend_tkinter_parameter_editor_table.py API start
8085
@property
8186
def connected_vehicle_type(self) -> str:
@@ -183,7 +188,7 @@ def handle_imu_temperature_calibration_workflow( # pylint: disable=too-many-arg
183188
show_error(_("Fatal error reading parameter files"), f"{exp}")
184189
raise
185190

186-
def should_copy_fc_values_to_file(self, selected_file: str) -> tuple[bool, Optional[dict], Optional[str]]:
191+
def _should_copy_fc_values_to_file(self, selected_file: str) -> tuple[bool, Optional[dict], Optional[str]]:
187192
"""
188193
Check if flight controller values should be copied to the specified file.
189194
@@ -207,7 +212,7 @@ def should_copy_fc_values_to_file(self, selected_file: str) -> tuple[bool, Optio
207212
return True, relevant_fc_params, auto_changed_by
208213
return False, None, auto_changed_by
209214

210-
def copy_fc_values_to_file(self, selected_file: str, relevant_fc_params: dict) -> bool:
215+
def _copy_fc_values_to_file(self, selected_file: str, relevant_fc_params: dict) -> bool:
211216
"""
212217
Copy FC values to the specified file.
213218
@@ -222,7 +227,75 @@ def copy_fc_values_to_file(self, selected_file: str, relevant_fc_params: dict) -
222227
params_copied = self._local_filesystem.copy_fc_values_to_file(selected_file, relevant_fc_params)
223228
return bool(params_copied)
224229

225-
def get_file_jump_options(self, selected_file: str) -> dict[str, str]:
230+
def handle_copy_fc_values_workflow(
231+
self,
232+
selected_file: str,
233+
ask_user_choice: ExperimentChoiceCallback,
234+
show_info: ShowInfoCallback,
235+
) -> ExperimentChoice:
236+
"""
237+
Handle the complete workflow for copying FC values to file with user interaction.
238+
239+
Args:
240+
selected_file: The configuration file to potentially update.
241+
ask_user_choice: Callback to ask user for choice (Yes/No/Close).
242+
show_info: Callback to show information messages.
243+
244+
Returns:
245+
ExperimentChoice: "close" if user chose to close, True if copied, False if no copy.
246+
247+
"""
248+
should_copy, relevant_fc_params, auto_changed_by = self._should_copy_fc_values_to_file(selected_file)
249+
if should_copy and relevant_fc_params and auto_changed_by:
250+
msg = _(
251+
"This configuration step requires external changes by: {auto_changed_by}\n\n"
252+
"The external tool experiment procedure is described in the tuning guide.\n\n"
253+
"Choose an option:\n"
254+
"* CLOSE - Close the application and go perform the experiment\n"
255+
"* YES - Copy current FC values to {selected_file} (if you've already completed the experiment)\n"
256+
"* NO - Continue without copying values (if you haven't performed the experiment yet,"
257+
" but know what you are doing)"
258+
).format(auto_changed_by=auto_changed_by, selected_file=selected_file)
259+
260+
user_choice = ask_user_choice(_("Update file with values from FC?"), msg, [_("Close"), _("Yes"), _("No")])
261+
262+
if user_choice is True: # Yes option
263+
params_copied = self._copy_fc_values_to_file(selected_file, relevant_fc_params)
264+
if params_copied:
265+
show_info(
266+
_("Parameters copied"),
267+
_("FC values have been copied to {selected_file}").format(selected_file=selected_file),
268+
)
269+
return user_choice
270+
return False
271+
272+
def handle_file_jump_workflow(
273+
self,
274+
selected_file: str,
275+
gui_complexity: str,
276+
ask_user_confirmation: AskConfirmationCallback,
277+
) -> str:
278+
"""
279+
Handle the complete workflow for file jumping with user interaction.
280+
281+
Args:
282+
selected_file: The current configuration file.
283+
gui_complexity: The GUI complexity setting ("simple" or other).
284+
ask_user_confirmation: Callback to ask user for confirmation.
285+
286+
Returns:
287+
str: The destination file to jump to, or the original file if no jump.
288+
289+
"""
290+
jump_options = self._get_file_jump_options(selected_file)
291+
for dest_file, msg in jump_options.items():
292+
if gui_complexity == "simple" or ask_user_confirmation(
293+
_("Skip some steps?"), _(msg) if msg else _("Skip to {dest_file}?").format(dest_file=dest_file)
294+
):
295+
return dest_file
296+
return selected_file
297+
298+
def _get_file_jump_options(self, selected_file: str) -> dict[str, str]:
226299
"""
227300
Get available file jump options for the selected file.
228301
@@ -235,11 +308,43 @@ def get_file_jump_options(self, selected_file: str) -> dict[str, str]:
235308
"""
236309
return self._local_filesystem.jump_possible(selected_file)
237310

311+
def handle_write_changes_workflow(
312+
self,
313+
annotate_params_into_files: bool,
314+
ask_user_confirmation: AskConfirmationCallback,
315+
) -> bool:
316+
"""
317+
Handle the workflow for writing changes to intermediate parameter file.
318+
319+
Args:
320+
at_least_one_param_edited: Whether any parameters have been edited.
321+
annotate_params_into_files: Whether to annotate documentation into files.
322+
ask_user_confirmation: Callback to ask user for confirmation.
323+
324+
Returns:
325+
bool: True if changes were written, False otherwise.
326+
327+
"""
328+
elapsed_since_last_ask = time() - self._last_time_asked_to_save
329+
# if annotate parameters into files is true, we always need to write to file, because
330+
# the parameter metadata might have changed, or not be present in the file.
331+
# In that situation, avoid asking multiple times to write the file, by checking the time last asked
332+
# But only if annotate_params_into_files is True
333+
if self._has_unsaved_changes() or (annotate_params_into_files and elapsed_since_last_ask > 1.0):
334+
msg = _("Do you want to write the changes to the {current_filename} file?").format(
335+
current_filename=self.current_file
336+
)
337+
if ask_user_confirmation(_("One or more parameters have been edited"), msg):
338+
self._export_current_file(annotate_doc=annotate_params_into_files)
339+
self._last_time_asked_to_save = time()
340+
return True
341+
return False
342+
238343
def should_download_file_from_url_workflow(
239344
self,
240345
selected_file: str,
241-
ask_confirmation: Callable[[str, str], bool],
242-
show_error: Callable[[str, str], None],
346+
ask_confirmation: AskConfirmationCallback,
347+
show_error: ShowErrorCallback,
243348
) -> bool:
244349
"""
245350
Handle file download workflow with injected GUI callbacks.
@@ -1360,7 +1465,7 @@ def get_parameters_as_par_dict(self, param_names: Optional[list[str]] = None) ->
13601465
}
13611466
)
13621467

1363-
def has_unsaved_changes(self) -> bool:
1468+
def _has_unsaved_changes(self) -> bool:
13641469
"""
13651470
Check if any changes have been made that need to be saved.
13661471
@@ -1408,7 +1513,7 @@ def configuration_phases(self) -> dict[str, PhaseData]:
14081513
def _write_current_file(self) -> None:
14091514
self._local_filesystem.write_last_uploaded_filename(self.current_file)
14101515

1411-
def export_current_file(self, annotate_doc: bool) -> None:
1516+
def _export_current_file(self, annotate_doc: bool) -> None:
14121517
# Convert domain model parameters to Par objects for export
14131518
export_params = self.get_parameters_as_par_dict()
14141519

ardupilot_methodic_configurator/frontend_tkinter_parameter_editor.py

Lines changed: 43 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
"""
1212

1313
import sys
14-
import time
1514
import tkinter as tk
1615
from argparse import ArgumentParser, Namespace
1716

@@ -21,7 +20,7 @@
2120
from logging import getLevelName as logging_getLevelName
2221
from logging import warning as logging_warning
2322
from tkinter import filedialog, messagebox, ttk
24-
from typing import Literal, Optional, Union
23+
from typing import Optional, Union
2524

2625
# from logging import critical as logging_critical
2726
from webbrowser import open as webbrowser_open # to open the blog post documentation
@@ -31,7 +30,7 @@
3130
from ardupilot_methodic_configurator.backend_filesystem_program_settings import ProgramSettings
3231
from ardupilot_methodic_configurator.backend_flightcontroller import FlightController
3332
from ardupilot_methodic_configurator.common_arguments import add_common_arguments
34-
from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager
33+
from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager, ExperimentChoice
3534
from ardupilot_methodic_configurator.frontend_tkinter_autoresize_combobox import AutoResizeCombobox
3635
from ardupilot_methodic_configurator.frontend_tkinter_base_window import (
3736
BaseWindow,
@@ -166,7 +165,6 @@ def __init__(self, configuration_manager: ConfigurationManager) -> None:
166165
self.tempcal_imu_progress_window: ProgressWindow
167166
self.file_upload_progress_window: ProgressWindow
168167
self.skip_button: ttk.Button
169-
self.last_time_asked_to_save: float = 0
170168
self.gui_complexity = str(ProgramSettings.get_setting("gui_complexity"))
171169

172170
self.root.title(
@@ -509,36 +507,24 @@ def select_file(title: str, filetypes: list[str]) -> Optional[str]:
509507
finally:
510508
self.tempcal_imu_progress_window.destroy()
511509

512-
def __handle_dialog_choice(self, result: list, dialog: tk.Toplevel, choice: Optional[bool]) -> None:
510+
def __handle_dialog_choice(self, result: list, dialog: tk.Toplevel, choice: ExperimentChoice) -> None:
513511
result.append(choice)
514512
dialog.destroy()
515513

516-
def __should_copy_fc_values_to_file(self, selected_file: str) -> None: # pylint: disable=too-many-locals
517-
should_copy, relevant_fc_params, auto_changed_by = self.configuration_manager.should_copy_fc_values_to_file(
518-
selected_file
519-
)
520-
if should_copy and relevant_fc_params and auto_changed_by:
521-
msg = _(
522-
"This configuration step requires external changes by: {auto_changed_by}\n\n"
523-
"The external tool experiment procedure is described in the tuning guide.\n\n"
524-
"Choose an option:\n"
525-
"* CLOSE - Close the application and go perform the experiment\n"
526-
"* YES - Copy current FC values to {selected_file} (if you've already completed the experiment)\n"
527-
"* NO - Continue without copying values (if you haven't performed the experiment yet,"
528-
" but know what you are doing)"
529-
).format(auto_changed_by=auto_changed_by, selected_file=selected_file)
530-
514+
def __should_copy_fc_values_to_file(self, selected_file: str) -> None:
515+
def ask_user_choice(title: str, message: str, options: list[str]) -> ExperimentChoice: # pylint: disable=too-many-locals
516+
"""GUI callback for asking user choice with custom dialog."""
531517
# Create custom dialog with Close, Yes, No buttons
532518
dialog = tk.Toplevel(self.root)
533519
# Hide dialog initially to prevent flickering
534520
dialog.withdraw()
535521
dialog.transient(self.root)
536-
dialog.title(_("Update file with values from FC?"))
522+
dialog.title(title)
537523
dialog.resizable(width=False, height=False)
538524
dialog.protocol("WM_DELETE_WINDOW", dialog.destroy)
539525

540526
# Message text
541-
message_label = tk.Label(dialog, text=msg, justify=tk.LEFT, padx=20, pady=10)
527+
message_label = tk.Label(dialog, text=message, justify=tk.LEFT, padx=20, pady=10)
542528
message_label.pack(padx=10, pady=10)
543529

544530
# Clickable link to tuning guide
@@ -554,7 +540,7 @@ def __should_copy_fc_values_to_file(self, selected_file: str) -> None: # pylint
554540
link_label.bind("<Button-1>", lambda _e: self.configuration_manager.open_documentation_in_browser(selected_file))
555541

556542
# Result variable
557-
result: list[Optional[Literal[True, False]]] = [None]
543+
result: list[ExperimentChoice] = []
558544

559545
# Button frame
560546
button_frame = tk.Frame(dialog)
@@ -563,25 +549,31 @@ def __should_copy_fc_values_to_file(self, selected_file: str) -> None: # pylint
563549
# Close button (default)
564550
close_button = tk.Button(
565551
button_frame,
566-
text=_("Close"),
552+
text=options[0], # "Close"
567553
width=10,
568-
command=lambda: self.__handle_dialog_choice(result, dialog, choice=None),
554+
command=lambda: self.__handle_dialog_choice(result, dialog, choice="close"),
569555
)
570556
close_button.pack(side=tk.LEFT, padx=5)
571557

572558
# Yes button
573559
yes_button = tk.Button(
574-
button_frame, text=_("Yes"), width=10, command=lambda: self.__handle_dialog_choice(result, dialog, choice=True)
560+
button_frame,
561+
text=options[1],
562+
width=10, # "Yes"
563+
command=lambda: self.__handle_dialog_choice(result, dialog, choice=True),
575564
)
576565
yes_button.pack(side=tk.LEFT, padx=5)
577566

578567
# No button
579568
no_button = tk.Button(
580-
button_frame, text=_("No"), width=10, command=lambda: self.__handle_dialog_choice(result, dialog, choice=False)
569+
button_frame,
570+
text=options[2],
571+
width=10, # "No"
572+
command=lambda: self.__handle_dialog_choice(result, dialog, choice=False),
581573
)
582574
no_button.pack(side=tk.LEFT, padx=5)
583575

584-
dialog.bind("<Return>", lambda _event: self.__handle_dialog_choice(result, dialog, None))
576+
dialog.bind("<Return>", lambda _event: self.__handle_dialog_choice(result, dialog, choice="close"))
585577

586578
# Center the dialog on the parent window
587579
dialog.deiconify()
@@ -604,23 +596,27 @@ def __should_copy_fc_values_to_file(self, selected_file: str) -> None: # pylint
604596

605597
# Wait until dialog is closed
606598
self.root.wait_window(dialog)
607-
response = result[-1] if len(result) > 1 else None
599+
return result[-1] if result else "close"
608600

609-
if response is True: # Yes option
610-
_params_copied = self.configuration_manager.copy_fc_values_to_file(selected_file, relevant_fc_params)
611-
elif response is None: # Close option
612-
sys.exit(0)
613-
# If response is False (No option), do nothing and continue
601+
result = self.configuration_manager.handle_copy_fc_values_workflow(
602+
selected_file,
603+
ask_user_choice,
604+
show_info_popup,
605+
)
606+
607+
if result == "close":
608+
# User chose to close the application
609+
sys.exit(0)
614610

615611
def __should_jump_to_file(self, selected_file: str) -> str:
616-
jump_options = self.configuration_manager.get_file_jump_options(selected_file)
617-
for dest_file, msg in jump_options.items():
618-
if self.gui_complexity == "simple" or messagebox.askyesno(
619-
_("Skip some steps?"), _(msg) if msg else _("Skip to {dest_file}?").format(**locals())
620-
):
621-
self.file_selection_combobox.set(dest_file)
622-
return dest_file
623-
return selected_file
612+
dest_file = self.configuration_manager.handle_file_jump_workflow(
613+
selected_file,
614+
self.gui_complexity,
615+
ask_yesno_popup,
616+
)
617+
if dest_file != selected_file:
618+
self.file_selection_combobox.set(dest_file)
619+
return dest_file
624620

625621
def __should_download_file_from_url(self, selected_file: str) -> None:
626622
self.configuration_manager.should_download_file_from_url_workflow(
@@ -699,7 +695,7 @@ def on_show_only_changed_checkbox_change(self) -> None:
699695

700696
def on_upload_selected_click(self) -> None:
701697
self.write_changes_to_intermediate_parameter_file()
702-
selected_params = self.parameter_editor_table.get_upload_selected_params(str(self.gui_complexity))
698+
selected_params = self.parameter_editor_table.get_upload_selected_params(self.gui_complexity)
703699
if selected_params:
704700
if self.configuration_manager.fc_parameters:
705701
self.upload_selected_params(selected_params)
@@ -804,20 +800,10 @@ def on_skip_click(self, _event: Union[None, tk.Event] = None) -> None:
804800
self.on_param_file_combobox_change(None)
805801

806802
def write_changes_to_intermediate_parameter_file(self) -> None:
807-
elapsed_since_last_ask = time.time() - self.last_time_asked_to_save
808-
# if annotate parameters into files is true, we always need to write to file, because
809-
# the parameter metadata might have changed, or not be present in the file.
810-
# In that situation, avoid asking multiple times to write the file, by checking the time last asked
811-
# But only if self.annotate_params_into_files.get()
812-
if self.configuration_manager.has_unsaved_changes() or (
813-
self.annotate_params_into_files.get() and elapsed_since_last_ask > 1.0
814-
):
815-
msg = _("Do you want to write the changes to the {current_filename} file?").format(
816-
current_filename=self.configuration_manager.current_file
817-
)
818-
if messagebox.askyesno(_("One or more parameters have been edited"), msg.format(**locals())):
819-
self.configuration_manager.export_current_file(annotate_doc=self.annotate_params_into_files.get())
820-
self.last_time_asked_to_save = time.time()
803+
self.configuration_manager.handle_write_changes_workflow(
804+
self.annotate_params_into_files.get(),
805+
ask_yesno_popup,
806+
)
821807

822808
def close_connection_and_quit(self) -> None:
823809
focused_widget = self.parameter_editor_table.view_port.focus_get()

0 commit comments

Comments
 (0)