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 @@ -350,8 +350,11 @@ def set_combobox_entries_preserving_width(
def populate_frames(self) -> None:
"""Populates the ScrollFrame with widgets based on the JSON data."""
components = self.data_model.get_all_components()
for key, value in components.items():
for i, (key, value) in enumerate(components.items(), 1):
self.add_widget(self.scroll_frame.view_port, key, value, [])
if i % 5 == 0: # yield to the event loop periodically to keep the UI responsive
self.scroll_frame.view_port.update_idletasks()
Comment on lines +353 to +356
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling update_idletasks() every 5 components can become a noticeable overhead for large component sets. Consider using a larger batch size and/or throttling based on elapsed time (e.g., yield every N ms) so performance scales better while still maintaining responsiveness.

Copilot uses AI. Check for mistakes.

# Scroll to the top after (re-)populating
self.scroll_frame.canvas.yview("moveto", 0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ def _update_table(self, params: dict[str, ArduPilotParameter], gui_complexity: s
if self.parameter_editor.should_display_bitmask_parameter_editor_usage(param_name):
should_try_to_display_bitmask_parameter_editor_usage = True
self._grid_column_widgets(row_widgets, i, show_upload_column)
if i % 20 == 0:
self.view_port.update_idletasks() # yield to the event loop periodically to keep the UI responsive
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_idletasks() only processes geometry redraw/idle tasks; it typically does not process pending input events, so it may not achieve the stated goal of yielding to the Tk event loop during long renders. If the intent is to keep the UI responsive to user actions, consider chunking the render via after(...) (preferred to avoid re-entrancy) or using update() with care (risk: re-entrant event handling while state is mid-update).

Suggested change
self.view_port.update_idletasks() # yield to the event loop periodically to keep the UI responsive
self.view_port.update() # process pending UI events periodically so the interface remains responsive during long renders

Copilot uses AI. Check for mistakes.

# Add the "Add" button at the bottom of the table
add_button = ttk.Button(self.view_port, text=_("Add"), style="narrow.TButton", command=self._on_parameter_add)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,6 @@ def on_frame_configure(self, _event) -> None: # noqa: ANN001
"""Reset the scroll region to encompass the inner frame."""
# Whenever the size of the frame changes, alter the scroll region respectively.
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
# Calculate the bounding box for the scroll region, starting from the second row
# bbox = self.canvas.bbox("all")
# if bbox:
# # Adjust the bounding box to start from the second row
# bbox = (bbox[0], bbox[1] + self.canvas.winfo_reqheight(), bbox[2], bbox[3])
# self.canvas.configure(scrollregion=bbox)

def on_canvas_configure(self, event: tk.Event) -> None:
"""Reset the canvas window to encompass inner frame when required."""
Expand All @@ -86,7 +80,8 @@ def on_canvas_configure(self, event: tk.Event) -> None:

def on_mouse_wheel(self, event: tk.Event) -> None: # cross platform scroll wheel event
canvas_height = self.canvas.winfo_height()
rows_height = self.canvas.bbox("all")[3]
bbox = self.canvas.bbox("all")
rows_height = bbox[3] if bbox is not None else 0
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

canvas.bbox('all') returns (x1, y1, x2, y2), so bbox[3] is a coordinate, not the content height. This can mis-detect overflow when y1 is not 0. Compute the height as (bbox[3] - bbox[1]) (and keep the None guard) to ensure scrolling is enabled/disabled correctly.

Suggested change
rows_height = bbox[3] if bbox is not None else 0
rows_height = (bbox[3] - bbox[1]) if bbox is not None else 0

Copilot uses AI. Check for mistakes.

if rows_height > canvas_height: # only scroll if the rows overflow the frame
if platform_system() == "Windows":
Expand Down
19 changes: 19 additions & 0 deletions tests/test_frontend_tkinter_component_editor_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,25 @@ def test_user_can_interact_with_different_widget_types(
# Assert: Widget addition should be called for both cases
assert editor_with_realistic_data._add_widget.call_count == 2

def test_populate_frames_yields_to_event_loop_every_five_components(
self, editor_with_realistic_data: ComponentEditorWindowBase
) -> None:
"""
populate_frames yields to the Tk event loop every fifth component to keep the UI responsive.

GIVEN: An editor with more than five components to display
WHEN: populate_frames is called
THEN: update_idletasks is called once per complete group of five components
"""
num_components = 15 # expect floor(15 / 5) == 3 yields
components = {f"Component_{i}": {"value": i} for i in range(num_components)}
editor_with_realistic_data.data_model.get_all_components.return_value = components

editor_with_realistic_data.populate_frames()

expected_yields = num_components // 5
assert editor_with_realistic_data.scroll_frame.view_port.update_idletasks.call_count == expected_yields


class TestComplexityComboboxWorkflows:
"""Test user workflows for GUI complexity management."""
Expand Down
27 changes: 27 additions & 0 deletions tests/test_frontend_tkinter_parameter_editor_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,33 @@ def test_update_table_creates_add_button_with_tooltip(self, parameter_editor_tab
tooltip_call_args = mock_tooltip.call_args[0]
assert "Add a parameter to the test_file.param file" in tooltip_call_args[1]

def test_update_table_yields_to_event_loop_every_twenty_rows(self, parameter_editor_table) -> None:
"""
Table update yields to the event loop periodically during large renders.

GIVEN: More than twenty parameters to render
WHEN: The table is updated
THEN: The viewport idletasks are processed every twentieth row
"""
params = {
f"TEST_PARAM_{index:02d}": create_mock_data_model_ardupilot_parameter(
name=f"TEST_PARAM_{index:02d}", value=float(index)
)
for index in range(1, 46)
}

with (
patch.object(parameter_editor_table, "_create_column_widgets", return_value=[MagicMock() for _ in range(7)]),
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This patch returns the same list instance (and the same widget mocks) for every _create_column_widgets() call, which can make the test brittle if the production code mutates the returned list or expects distinct widget instances per row. Prefer using a side_effect that returns a fresh list of new mocks each call.

Copilot uses AI. Check for mistakes.
patch.object(parameter_editor_table, "_grid_column_widgets"),
patch.object(parameter_editor_table, "_configure_table_columns"),
patch.object(parameter_editor_table.view_port, "update_idletasks") as mock_update_idletasks,
patch("tkinter.ttk.Button"),
patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.show_tooltip"),
):
parameter_editor_table._update_table(params, "simple")

assert mock_update_idletasks.call_count == 2

def test_create_flightcontroller_value_sets_correct_background_colors(self, parameter_editor_table) -> None: # pylint: disable=too-many-statements # noqa: PLR0915
"""
Flight controller value labels display with appropriate background colors based on parameter state.
Expand Down
19 changes: 19 additions & 0 deletions tests/test_frontend_tkinter_scroll_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,25 @@ def test_user_does_not_scroll_when_all_content_is_visible(self, scrollable_frame
# Verify no scrolling occurred
scrollable_frame.canvas.yview_scroll.assert_not_called()

def test_user_does_not_crash_when_canvas_has_no_items(self, scrollable_frame) -> None:
"""
User does not hit a crash when scrolling before any canvas items exist.

GIVEN: A ScrollFrame whose canvas reports no bounding box yet
WHEN: The user scrolls
THEN: No scrolling occurs and no exception is raised
"""
scrollable_frame.canvas.winfo_height.return_value = 100
scrollable_frame.canvas.bbox.return_value = None

event = MagicMock()
event.delta = 120

with patch("ardupilot_methodic_configurator.frontend_tkinter_scroll_frame.platform_system", return_value="Windows"):
scrollable_frame.on_mouse_wheel(event)

scrollable_frame.canvas.yview_scroll.assert_not_called()

def test_user_mouse_events_are_properly_managed_on_enter_leave(self, scrollable_frame) -> None:
"""
User mouse events are properly bound and unbound as cursor enters/leaves the scroll area.
Expand Down
Loading