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
219 changes: 207 additions & 12 deletions ardupilot_methodic_configurator/frontend_tkinter_parameter_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import contextlib
import re
import tempfile
import threading
import tkinter as tk
Expand Down Expand Up @@ -70,7 +71,8 @@
update_flight_controller_restart_progress,
)
from ardupilot_methodic_configurator.frontend_tkinter_rich_text import RichText, get_widget_font_family_and_size
from ardupilot_methodic_configurator.frontend_tkinter_show import show_tooltip
from ardupilot_methodic_configurator.frontend_tkinter_scroll_frame import ScrollFrame
from ardupilot_methodic_configurator.frontend_tkinter_show import get_monitor_bounds, show_tooltip
from ardupilot_methodic_configurator.frontend_tkinter_stage_progress import StageProgressBar
from ardupilot_methodic_configurator.frontend_tkinter_usage_popup_window import UsagePopupWindow
from ardupilot_methodic_configurator.frontend_tkinter_usage_popup_windows import (
Expand Down Expand Up @@ -346,6 +348,10 @@ def __init__(
self._file_browser_window: FileBrowserWindow | None = None
self._log_report_return_pending: bool = False
self.inline_component_editor: ComponentEditorWindow | None = None
self.inline_component_scroll_frame: ScrollFrame | None = None
self._inline_component_chrome_height = 0
self._parameter_area_resize_pending = False
self._parameter_area_resize_running = False
self._inline_component_name: str | None = None
self._updating_inline_editor: bool = False
self._requested_action = ParameterEditorAction.FINISHED
Expand Down Expand Up @@ -385,9 +391,8 @@ def __init__(
# Resize window height to ensure all widgets, including the skip button, are fully visible
# as some Linux Window managers like KDE, like to change font sizes and padding.
# So we need to dynamically accommodate for that after placing the widgets
self.root.update_idletasks()
req_height = max(self.root.winfo_reqheight(), round(820 * self.dpi_scaling_factor))
self.root.geometry(f"{round(990 * self.dpi_scaling_factor)}x{req_height}")
self._fit_window_to_screen_height(initialize=True)
self._bind_parameter_layout_events()

# Set up startup notification for the main application window
FreeDesktop.setup_startup_notification(self.root) # type: ignore[arg-type]
Expand Down Expand Up @@ -1103,9 +1108,29 @@ def _swap_plugin_in_place(self, plugin: dict) -> None: # pylint: disable=too-ma

# Load new plugin into existing frame
self._load_plugin(plugin_parent, plugin)
if placement == "top":
self._configure_top_plugin_rows(plugin_parent)

self.current_plugin = plugin

def _configure_top_plugin_rows(self, plugin_parent: ttk.Frame) -> None:
"""Only let top plugins yield height when their content can be scrolled."""
widgets: list[tk.Misc] = [plugin_parent]
scrollable = False
while widgets and not scrollable:
widget = widgets.pop()
scrollable = isinstance(widget, ScrollFrame)
if not scrollable:
widgets.extend(widget.winfo_children())

top_container = cast("ttk.Frame", plugin_parent.master)
# A zero-weight row keeps its natural requested height, including when
# feedback text grows after Apply. Do not freeze a measured minsize.
top_container.rowconfigure(0, weight=int(scrollable), minsize=0)
# The table scrolls, so it takes the shortfall for fixed-height plugins.
# Scrollable assistants instead share space and retain editable rows.
top_container.rowconfigure(2, weight=1, minsize=round(120 * self.dpi_scaling_factor) if scrollable else 0)

def _rebuild_plugin_layout(self, plugin: dict | None) -> None:
"""
Completely rebuild the plugin layout structure.
Expand All @@ -1128,6 +1153,7 @@ def _rebuild_plugin_layout(self, plugin: dict | None) -> None:
self.parameter_area_container.pack(side="top", fill="both", expand=True)

self.current_plugin = plugin
plugin_parent: ttk.Frame | None = None

if plugin and plugin.get("placement") == "left":
# Left placement: Create horizontal split with plugin on left, parameters on right
Expand All @@ -1140,8 +1166,7 @@ def _rebuild_plugin_layout(self, plugin: dict | None) -> None:
self.parameter_area_paned.add(left_frame, minsize=500, stretch="always")
self._set_pane_sticky(self.parameter_area_paned, left_frame)

# Instantiate and display the plugin in the left frame
self._load_plugin(left_frame, plugin)
plugin_parent = left_frame

# Create right pane for parameter table (gets remaining space)
right_frame = ttk.Frame(self.parameter_area_paned)
Expand All @@ -1154,18 +1179,21 @@ def _rebuild_plugin_layout(self, plugin: dict | None) -> None:
# This gives plugin full horizontal width, useful for wide controls
top_container = ttk.Frame(self.parameter_area_container)
top_container.pack(side="top", fill="both", expand=True)
top_container.columnconfigure(0, weight=1)
top_container.rowconfigure(0, weight=0)
top_container.rowconfigure(2, weight=1)

# Create top frame for plugin (fixed height, expands horizontally only)
# Preserve controls until the loaded view is known to be scrollable.
top_plugin_frame = ttk.Frame(top_container)
top_plugin_frame.pack(side="top", fill="both", expand=True)
self._load_plugin(top_plugin_frame, plugin)
top_plugin_frame.grid(row=0, column=0, sticky="nsew")
plugin_parent = top_plugin_frame

# Visual separator between plugin and parameter table
ttk.Separator(top_container, orient="horizontal").pack(side="top", fill="x", pady=2)
ttk.Separator(top_container, orient="horizontal").grid(row=1, column=0, sticky="ew", pady=2)

# Create bottom frame for parameter table (takes remaining vertical space)
bottom_frame = ttk.Frame(top_container)
bottom_frame.pack(side="top", fill="both", expand=True)
bottom_frame.grid(row=2, column=0, sticky="nsew")

self.parameter_container = bottom_frame
else:
Expand All @@ -1178,6 +1206,14 @@ def _rebuild_plugin_layout(self, plugin: dict | None) -> None:
self.parameter_editor_table = ParameterEditorTable(self.parameter_container, self.parameter_editor, self)
self.parameter_editor_table.pack(side="top", fill="both", expand=True)

# Plugin construction or activation can open a modal dialog, which runs
# a nested event loop. Build both panes and the table before that loop
# can measure the layout; adding a pane later does not reclaim its width.
if plugin_parent is not None and plugin is not None:
self._load_plugin(plugin_parent, plugin)
if plugin.get("placement") == "top":
self._configure_top_plugin_rows(plugin_parent)

@staticmethod
def _set_pane_sticky(paned_window: tk.PanedWindow, pane: tk.Widget) -> None:
"""Type-safe helper to configure pane stickiness on paned windows."""
Expand Down Expand Up @@ -1588,12 +1624,14 @@ def _update_inline_component_editor(self, component_name: str | None) -> None:
for child in self.inline_component_container.winfo_children():
child.destroy()
self.inline_component_editor = None
self.inline_component_scroll_frame = None
self._inline_component_name = None

if component_name is None:
# Hide the container when no inline component editor is required.
self.inline_component_container.configure(text="")
self.inline_component_container.pack_forget()
self._fit_window_to_screen_height()
return

# Show (or re-show) the container with the new component name.
Expand All @@ -1603,17 +1641,174 @@ def _update_inline_component_editor(self, component_name: str | None) -> None:
side=tk.TOP, fill="x", expand=False, padx=(4, 4), before=self.parameter_area_container
)

self.inline_component_container.pack_propagate(flag=True)
scroll_frame = ScrollFrame(self.inline_component_container)
scroll_frame.canvas.configure(height=1)
scroll_frame.pack(side="top", fill="both", expand=True)
deps: ComponentEditorDeps = self.parameter_editor.get_component_editor_deps()
editor = ComponentEditorWindow(
__version__,
deps.local_filesystem,
deps.fc_parameters,
embedded_parent_frame=self.inline_component_container,
embedded_parent_frame=scroll_frame.view_port,
on_component_change=self._on_component_data_changed,
)
editor.populate_single_component(component_name)
self.inline_component_editor = editor
self._inline_component_name = component_name
self.root.update_idletasks()
self._inline_component_chrome_height = (
self.inline_component_container.winfo_reqheight() - scroll_frame.winfo_reqheight()
)
self.inline_component_scroll_frame = scroll_frame
scroll_frame.view_port.bind("<Configure>", self._on_main_frame_resize, add="+")
self._fit_window_to_screen_height()

def _window_manager_controls_geometry(self) -> bool:
"""Recognize maximised and full-screen windows across Tk platforms."""
if self.root.state() == "zoomed":
return True
for attribute in ("-zoomed", "-fullscreen"):
# -zoomed is an X11 attribute, unsupported by some other Tk platforms.
with contextlib.suppress(tk.TclError):
if self.root.tk.getboolean(self.root.attributes(attribute)):
return True
return False

def _fit_window_to_screen_height(self, *, initialize: bool = False) -> None:
"""Set the startup size, then preserve user sizing unless it exceeds the monitor."""
self.root.update_idletasks()
if self._window_manager_controls_geometry():
self._resize_parameter_area_to_fit()
return
bounds = get_monitor_bounds(self.root)
root_y = self.root.winfo_rooty()
current_height = self.root.winfo_height()
if not initialize and bounds.top <= root_y and root_y + current_height <= bounds.bottom:
self._resize_parameter_area_to_fit()
return
# Reserve space above and below the client area for window decorations and panels.
margin = round(40 * self.dpi_scaling_factor)
available_height = max(1, bounds.bottom - bounds.top - 2 * margin)
requested_height = (
max(self.root.winfo_reqheight(), round(820 * self.dpi_scaling_factor)) if initialize else self.root.winfo_height()
)
window_height = min(requested_height, available_height)
target_y = max(bounds.top + margin, min(root_y, bounds.bottom - margin - window_height))
if initialize or window_height != requested_height or target_y != root_y:
window_width = round(990 * self.dpi_scaling_factor) if initialize else self.root.winfo_width()
# wm geometry uses frame coordinates, while winfo reports the client
# position on X11. Apply the client displacement to the WM position
# and preserve its horizontal anchor, avoiding decoration-sized drift.
geometry = re.fullmatch(r"\d+x\d+([+-]-?\d+)([+-]-?\d+)", self.root.geometry())
if geometry is not None:
window_x, window_y = geometry.groups()
displacement = target_y - root_y
if window_y.startswith("+"):
window_y = f"+{int(window_y[1:]) + displacement}"
else:
# A minus sign anchors the window to the bottom of the screen.
window_y = f"-{int(window_y[1:]) + current_height - window_height - displacement}"
self.root.geometry(f"{window_width}x{window_height}{window_x}{window_y}")

self.root.update_idletasks()
self._resize_parameter_area_to_fit()

def _bind_parameter_layout_events(self) -> None:
"""Refit when the window or any fixed section changes its allocated size."""
self.main_frame.bind("<Configure>", self._on_main_frame_resize, add="+")
for widget in self.main_frame.winfo_children():
if widget is not self.parameter_area_container:
widget.bind("<Configure>", self._on_main_frame_resize, add="+")

def _on_main_frame_resize(self, _event: tk.Event) -> None:
"""Fit once idle, after fixed controls have re-wrapped at the new width."""
if not self._parameter_area_resize_pending and not self._parameter_area_resize_running:
self._parameter_area_resize_pending = True
self.root.after_idle(self._resize_parameter_area_to_fit)

def _minimum_parameter_area_height(self) -> int:
"""Reserve table space alongside, or below, the current plugin."""
minimum_height = round(120 * self.dpi_scaling_factor)
if self.current_plugin and self.current_plugin_view is not None:
plugin_height = cast("tk.Widget", self.current_plugin_view).winfo_reqheight()
placement = self.current_plugin.get("placement")
if placement == "left":
minimum_height = max(minimum_height, plugin_height)
elif placement == "top":
# Include the separator's height and padding in the vertical stack.
minimum_height += plugin_height + 6
return minimum_height

def _resize_parameter_area_to_fit(self) -> None: # pylint: disable=too-many-locals,too-many-branches
"""Give the parameter area the height left after all fixed controls are placed."""
if self._parameter_area_resize_running:
return

self._parameter_area_resize_pending = False
if int(str(self.parameter_area_container.cget("height"))) == 0:
# Measure new panes before fixing their height, so the parameter
# table has a requested width when the PanedWindow first lays out.
self._parameter_area_resize_running = True
try:
self.parameter_area_container.update_idletasks()
finally:
self._parameter_area_resize_running = False

# The parameter area is the only expandable section between the fixed
# header/component editor and the bottom buttons. Give it only the height
# left after those other widgets have been placed, so its requested size
# cannot consume the button row on short screens.
fixed_height = 0
for widget in self.main_frame.winfo_children():
if widget is self.parameter_area_container or widget.winfo_manager() != "pack":
continue
pack_info = cast("tk.Pack", widget).pack_info()
vertical_padding = pack_info.get("pady", 0)
padding_values: tuple[int | str, ...]
if isinstance(vertical_padding, tuple):
padding_values = vertical_padding
elif isinstance(vertical_padding, str):
padding_values = self.root.tk.splitlist(vertical_padding)
else:
padding_values = (vertical_padding,)
padding_height = sum(widget.winfo_pixels(value) for value in padding_values)
if len(padding_values) == 1:
padding_height *= 2
if widget is self.inline_component_container and self.inline_component_scroll_frame is not None:
fixed_height += padding_height
else:
fixed_height += widget.winfo_reqheight() + padding_height

if self.inline_component_scroll_frame is not None:
# Keep a useful parameter table on component-heavy steps such as Battery.
# Extra component fields remain accessible through the existing ScrollFrame.
scroll_frame = self.inline_component_scroll_frame
content_height = scroll_frame.view_port.winfo_reqheight() + 8
desired_height = content_height + self._inline_component_chrome_height
minimum_table_height = self._minimum_parameter_area_height()
available_height = max(1, self.main_frame.winfo_height() - fixed_height)
minimum_component_height = round(80 * self.dpi_scaling_factor)
# Share very limited space equally until the component minimum fits.
# Then let the parameter area reach its minimum before growing the component
# panel further. Both allocations grow continuously with the window.
component_budget = max(
min(minimum_component_height, available_height // 2), available_height - minimum_table_height
)
component_height = min(desired_height, max(1, component_budget))
scroll_frame.canvas.configure(height=content_height)
requested_height = self.inline_component_container.winfo_reqheight()
configured_height = int(str(self.inline_component_container.cget("height")))
if configured_height != component_height or requested_height != component_height:
self.inline_component_container.configure(height=component_height)
self.inline_component_container.pack_propagate(flag=False)
fixed_height += component_height

table_height = max(1, self.main_frame.winfo_height() - fixed_height)
configured_height = int(str(self.parameter_area_container.cget("height")))
if configured_height != table_height:
self.parameter_area_container.pack_propagate(flag=False)
self.parameter_area_container.configure(height=table_height)

def on_show_only_changed_checkbox_change(self) -> None:
self.repopulate_parameter_table()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
OperationNotPossibleError,
ParameterValueUpdateStatus,
)
from ardupilot_methodic_configurator.frontend_tkinter_scroll_frame import ScrollFrame
from ardupilot_methodic_configurator.plugins.frontend_tkinter_helpers import refresh_parameter_editor_table
from ardupilot_methodic_configurator.plugins.imu_helpers import (
ImuPollHandlers,
Expand Down Expand Up @@ -65,7 +66,9 @@ def __init__(
self._start_imu_polling()

def _setup_ui(self) -> None:
main_frame = ttk.Frame(self)
self._scroll_frame = ScrollFrame(self)
self._scroll_frame.pack(fill="both", expand=True)
main_frame = ttk.Frame(self._scroll_frame.view_port)
main_frame.pack(fill="both", expand=True)

manual_option_frame = ttk.LabelFrame(
Expand Down
Loading
Loading