Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ def __init__( # pylint: disable=too-many-arguments, too-many-positional-argumen
def on_select_directory(self) -> bool:
if self.is_template_selection:
if isinstance(self.parent.root, tk.Tk): # this keeps mypy and pyright happy
TemplateOverviewWindow(self.parent.root)
to = TemplateOverviewWindow(self.parent.root)
to.run_app()
selected_directory = ProgramSettings.get_recently_used_dirs()[0]
logging_info(_("Selected template directory: %s"), selected_directory)
else:
Expand Down
175 changes: 120 additions & 55 deletions ardupilot_methodic_configurator/frontend_tkinter_template_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
import argparse
import tkinter as tk
from logging import basicConfig as logging_basicConfig
from logging import debug as logging_debug
from logging import getLevelName as logging_getLevelName
from logging import info as logging_info
from tkinter import font as tkfont
from tkinter import ttk
from typing import Optional

Expand All @@ -39,14 +40,20 @@ class TemplateOverviewWindow(BaseWindow):

Attributes:
window (tk.Tk|None): The root Tkinter window object for the GUI.

Methods:
on_row_double_click(event): Handles the event triggered when a row in the Treeview is double-clicked, allowing the user
to store the corresponding template directory.
sort_column (str): The column currently being used for sorting
tree (ttk.Treeview): The treeview widget displaying templates
image_label (ttk.Label): Label for displaying vehicle images

"""

def __init__(self, parent: Optional[tk.Tk] = None) -> None:
"""
Initialize the TemplateOverviewWindow.

Args:
parent: Optional parent Tk window

"""
super().__init__(parent)
title = _("Amilcar Lucas's - ArduPilot methodic configurator {} - Template Overview and selection")
self.root.title(title.format(__version__))
Expand All @@ -63,8 +70,24 @@ def __init__(self, parent: Optional[tk.Tk] = None) -> None:
self.image_label = ttk.Label(self.top_frame)
self.image_label.pack(side=tk.RIGHT, anchor=tk.NE, padx=(20, 20), pady=IMAGE_HEIGHT_PX / 2)

self.sort_column: str
self.sort_column: str = ""
self._setup_treeview()
self._bind_events()

def run_app(self) -> None:
"""Run the TemplateOverviewWindow application."""
if isinstance(self.root, tk.Toplevel):
try:
while self.root.children:
self.root.update_idletasks()
self.root.update()
except tk.TclError as _exp:
pass
elif isinstance(self.root, tk.Tk):
self.root.mainloop()

def _setup_treeview(self) -> None:
"""Set up the treeview with columns and styling."""
style = ttk.Style(self.root)
# Add padding to Treeview heading style
style.layout(
Expand Down Expand Up @@ -99,76 +122,87 @@ def __init__(self, parent: Optional[tk.Tk] = None) -> None:
for col in columns:
self.tree.heading(col, text=col)

# Populate the Treeview with data from the template overview
self._populate_treeview()
self._adjust_treeview_column_widths()
self.tree.pack(fill=tk.BOTH, expand=True)

def _populate_treeview(self) -> None:
"""Populate the treeview with data from vehicle components."""
for key, template_overview in VehicleComponents.get_vehicle_components_overviews().items():
attribute_names = template_overview.attributes()
values = (key, *(getattr(template_overview, attr, "") for attr in attribute_names))
self.tree.insert("", "end", text=key, values=values)

self._adjust_treeview_column_widths()

self.tree.bind("<ButtonRelease-1>", self.__on_row_selection_change)
self.tree.bind("<Up>", self.__on_row_selection_change)
self.tree.bind("<Down>", self.__on_row_selection_change)
self.tree.bind("<Double-1>", self.__on_row_double_click)
self.tree.pack(fill=tk.BOTH, expand=True)

for col in self.tree["columns"]:
col_str = str(col)
self.tree.heading(
col_str,
text=col_str,
command=lambda col2=col_str: self.__sort_by_column(col2, reverse=False), # type: ignore[misc]
)

if isinstance(self.root, tk.Toplevel):
try:
while self.root.children:
self.root.update_idletasks()
self.root.update()
except tk.TclError as _exp:
pass
else:
self.root.mainloop()

def _adjust_treeview_column_widths(self) -> None:
"""Adjusts the column widths of the Treeview to fit the contents of each column."""
for col in self.tree["columns"]:
max_width = 0
for subtitle in col.title().split("\n"):
max_width = max(max_width, tk.font.Font().measure(subtitle)) # pyright: ignore[reportAttributeAccessIssue]
max_width = max(max_width, tkfont.Font().measure(subtitle))

# Iterate over all rows and update the max_width if a wider entry is found
for item in self.tree.get_children():
item_text = self.tree.item(item, "values")[self.tree["columns"].index(col)]
text_width = tk.font.Font().measure(item_text) # pyright: ignore[reportAttributeAccessIssue]
text_width = tkfont.Font().measure(item_text)
max_width = max(max_width, text_width)

# Update the column's width property to accommodate the largest text width
self.tree.column(col, width=int(max_width * 0.6 + 10))

def __on_row_selection_change(self, _event: tk.Event) -> None:
def _bind_events(self) -> None:
"""Bind events to the treeview."""
self.tree.bind("<ButtonRelease-1>", self._on_row_selection_change)
self.tree.bind("<Up>", self._on_row_selection_change)
self.tree.bind("<Down>", self._on_row_selection_change)
self.tree.bind("<Double-1>", self._on_row_double_click)

for col in self.tree["columns"]:
col_str = str(col)
self.tree.heading(
col_str,
text=col_str,
command=lambda col2=col_str: self._sort_by_column(col2, reverse=False), # type: ignore[misc]
)

def _on_row_selection_change(self, _event: tk.Event) -> None:
"""Handle row single-click event."""
self.root.after(0, self.__update_selection)
self.root.after(0, self._update_selection)

def __update_selection(self) -> None:
def _update_selection(self) -> None:
"""Update selection after keypress event."""
selected_item = self.tree.selection()
if selected_item:
item_id = selected_item[0]
selected_template_relative_path = self.tree.item(item_id)["text"]
ProgramSettings.store_template_dir(selected_template_relative_path)
self.store_template_dir(selected_template_relative_path)
self._display_vehicle_image(selected_template_relative_path)

def __on_row_double_click(self, event: tk.Event) -> None:
def store_template_dir(self, template_path: str) -> None:
"""
Store the selected template directory.

This method is separated from the UI event handler to improve testability.

Args:
template_path: The path to store

"""
ProgramSettings.store_template_dir(template_path)

def _on_row_double_click(self, event: tk.Event) -> None:
"""Handle row double-click event."""
item_id = self.tree.identify_row(event.y)
if item_id:
selected_template_relative_path = self.tree.item(item_id)["text"]
ProgramSettings.store_template_dir(selected_template_relative_path)
self.root.destroy()
self.store_template_dir(selected_template_relative_path)
self.close_window()

def close_window(self) -> None:
"""Close the window - separated for testability."""
self.root.destroy()

def __sort_by_column(self, col: str, reverse: bool) -> None:
def _sort_by_column(self, col: str, reverse: bool) -> None:
"""Sort treeview items by the specified column."""
if hasattr(self, "sort_column") and self.sort_column and self.sort_column != col:
self.tree.heading(self.sort_column, text=self.sort_column)
self.tree.heading(col, text=col + (" ▼" if reverse else " ▲"))
Expand All @@ -185,7 +219,7 @@ def __sort_by_column(self, col: str, reverse: bool) -> None:
self.tree.move(k, "", index)

# reverse sort next time
self.tree.heading(col, command=lambda: self.__sort_by_column(col, not reverse))
self.tree.heading(col, command=lambda: self._sort_by_column(col, not reverse))

def _display_vehicle_image(self, template_path: str) -> None:
"""Display the vehicle image corresponding to the selected template."""
Expand All @@ -194,7 +228,7 @@ def _display_vehicle_image(self, template_path: str) -> None:
if isinstance(widget, ttk.Label) and widget == self.image_label:
widget.destroy()
try:
vehicle_image_filepath = VehicleComponents.get_vehicle_image_filepath(template_path)
vehicle_image_filepath = self.get_vehicle_image_filepath(template_path)
self.image_label = self.put_image_in_label(self.top_frame, vehicle_image_filepath, IMAGE_HEIGHT_PX)
except FileNotFoundError:
self.image_label = ttk.Label(
Expand All @@ -204,6 +238,24 @@ def _display_vehicle_image(self, template_path: str) -> None:
)
self.image_label.pack(side=tk.RIGHT, anchor=tk.NE, padx=(4, 0), pady=(0, 0))

def get_vehicle_image_filepath(self, template_path: str) -> str:
"""
Get the filepath for a vehicle image.

Separated from display method for testability.

Args:
template_path: Path to the template

Returns:
Path to the vehicle image

Raises:
FileNotFoundError: If the image file doesn't exist

"""
return VehicleComponents.get_vehicle_image_filepath(template_path)


def argument_parser() -> argparse.Namespace:
"""
Expand All @@ -217,25 +269,38 @@ def argument_parser() -> argparse.Namespace:
"""
parser = argparse.ArgumentParser(
description=_(
"ArduPilot methodic configurator is a GUI-based tool designed to simplify "
"the management and visualization of ArduPilot parameters. It enables users "
"to browse through various vehicle templates, edit parameter files, and "
"apply changes directly to the flight controller. The tool is built to "
"semi-automate the configuration process of ArduPilot for drones by "
"providing a clear and intuitive interface for parameter management."
"ArduPilot Template Overview - A component of the ArduPilot Methodic Configurator suite. "
"This tool presents available vehicle templates in a user-friendly interface, allowing you "
"to browse, compare, and select the most appropriate template for your vehicle configuration. "
"Select a template that most closely resembles your vehicle's component setup to streamline "
"the configuration process. The selected template will serve as a starting point for more "
"detailed parameter configuration."
)
)
return add_common_arguments(parser).parse_args()


def setup_logging(loglevel: str) -> None:
"""
Set up logging with the specified log level.

Args:
loglevel: The log level as a string (e.g. 'DEBUG', 'INFO')

"""
logging_basicConfig(level=logging_getLevelName(loglevel), format="%(asctime)s - %(levelname)s - %(message)s")


def main() -> None:
"""Main entry point for the application."""
args = argument_parser()
setup_logging(args.loglevel)

logging_basicConfig(level=logging_getLevelName(args.loglevel), format="%(asctime)s - %(levelname)s - %(message)s")

TemplateOverviewWindow(None)
window = TemplateOverviewWindow()
window.run_app()

logging_debug(ProgramSettings.get_recently_used_dirs()[0])
if window and ProgramSettings.get_recently_used_dirs():
logging_info(ProgramSettings.get_recently_used_dirs()[0])


if __name__ == "__main__":
Expand Down
Loading
Loading