From 2fb68c1e87476241e47e410322a359f71d5142c6 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Thu, 13 Mar 2025 11:34:35 -0500 Subject: [PATCH 01/17] autogeneration-preliminary-work --- docs/_scripts/autogenerate_gui_images.py | 167 +++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/_scripts/autogenerate_gui_images.py diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py new file mode 100644 index 000000000..a9986e39d --- /dev/null +++ b/docs/_scripts/autogenerate_gui_images.py @@ -0,0 +1,167 @@ +from pathlib import Path + +from qtpy.QtCore import QTimer +from napari._qt.qt_event_loop import get_qapp +from napari._qt.qt_resources import get_stylesheet +import napari + +DOCS = REPO_ROOT_PATH = Path(__file__).resolve().parent.parent +IMAGES_PATH = DOCS / "images" / "_autogenerated" +IMAGES_PATH.mkdir(parents=True, exist_ok=True) + +def autogenerate_images(): + app = get_qapp() + + # Create viewer with visible window + viewer = napari.Viewer(show=True) + viewer.window._qt_window.resize(800, 600) + viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) + + # Add sample data + viewer.open_sample(plugin='napari', sample='cells3d') + + # Print Qt widget hierarchy + print_qt_attributes(viewer) + print_widget_hierarchy(viewer.window._qt_window) + + # Wait for viewer to fully initialize and render + QTimer.singleShot(2000, lambda: capture_elements(viewer)) + + app.exec_() + +def capture_elements(viewer): + """Capture specific UI elements based on the widget hierarchy.""" + + # Main components - using the hierarchy you provided + components = { + "welcome_widget": find_widget_by_class(viewer.window._qt_window, "QtWelcomeWidget"), + "console": find_widget_by_class(viewer.window._qt_window, "QtConsole"), + "console_dock": find_widget_by_name(viewer.window._qt_window, "console"), + + "dimension_slider": find_widget_by_class(viewer.window._qt_window, "QtDims"), #QtDimSliderWidget + + # Layer list components + "layer_list_dock": find_widget_by_name(viewer.window._qt_window, "layer list"), + "layer_buttons": find_widget_by_class(viewer.window._qt_window, "QtLayerButtons"), + "layer_list": find_widget_by_class(viewer.window._qt_window, "QtLayerList"), + "viewer_buttons": find_widget_by_class(viewer.window._qt_window, "QtViewerButtons"), + + # Layer controls + "layer_controls_dock": find_widget_by_name(viewer.window._qt_window, "layer controls"), + + # TODO: mouse over part of the image to show intensity stuff + "status_bar": viewer.window._status_bar, + + # Menus + "menu_bar": find_widget_by_class(viewer.window._qt_window, "QMenuBar"), + "file_menu": find_widget_by_name(viewer.window._qt_window, "napari/file"), + "samples_menu": find_widget_by_name(viewer.window._qt_window, "napari/file/samples/napari"), + "view_menu": find_widget_by_name(viewer.window._qt_window, "napari/view"), + "layers_menu": find_widget_by_name(viewer.window._qt_window, "napari/layers"), + "plugins_menu": find_widget_by_name(viewer.window._qt_window, "napari/plugins"), + "window_menu": find_widget_by_name(viewer.window._qt_window, "napari/window"), + "help_menu": find_widget_by_name(viewer.window._qt_window, "napari/help"), + } + + # Capture each component + for name, widget in components.items(): + try: + if widget is None: + print(f"Could not find {name}") + continue + + # For menus, need to show them first + if name.endswith('_menu'): + show_menu_for_screenshot(widget, name) + continue + + pixmap = widget.grab() + pixmap.save(str(IMAGES_PATH / f"{name}.png")) + except Exception as e: + print(f"Error capturing {name}: {e}") + + # TODO: This needs to be done at the end of capture_elements and not autogenerate_images, why? + QTimer.singleShot(2000, lambda: close_all(viewer)) + +def show_menu_for_screenshot(menu, name): + """Show a menu and take screenshot of it.""" + menu.popup(menu.parent().mapToGlobal(menu.pos())) + + # Give menu time to appear + def grab_menu(): + pixmap = menu.grab() + pixmap.save(str(IMAGES_PATH / f"{name}.png")) + menu.hide() + + QTimer.singleShot(300, grab_menu) + +def find_widget_by_name(parent, name): + """Find a widget by its object name.""" + if parent.objectName() == name: + return parent + + for child in parent.children(): + if hasattr(child, 'objectName') and child.objectName() == name: + return child + + if hasattr(child, 'children'): + found = find_widget_by_name(child, name) + if found: + return found + + return None + +def find_widget_by_class(parent, class_name): + """Find a child widget by its class name.""" + for child in parent.children(): + if child.__class__.__name__ == class_name: + return child + + if hasattr(child, 'children'): + found = find_widget_by_class(child, class_name) + if found: + return found + + return None + +def close_all(viewer): + print("Closing viewer") + viewer.close() + QTimer.singleShot(100, lambda: get_qapp().quit()) + +def print_qt_attributes(viewer): + """Print important Qt-related attributes of the napari viewer.""" + qt_window = viewer.window._qt_window + qt_viewer = viewer.window.qt_viewer + + print("\nQt Window Attributes:") + for attr in dir(qt_window): + if not attr.startswith('_') and hasattr(getattr(qt_window, attr), 'objectName'): + obj = getattr(qt_window, attr) + name = obj.objectName() + print(f" {attr}: {obj.__class__.__name__}{f' (name: {name})' if name else ''}") + + print("\nQt Viewer Attributes:") + for attr in dir(qt_viewer): + if not attr.startswith('_') and not callable(getattr(qt_viewer, attr)): + try: + obj = getattr(qt_viewer, attr) + class_name = obj.__class__.__name__ + print(f" {attr}: {class_name}") + except: + pass + +def print_widget_hierarchy(widget, indent=0): + """Print a hierarchy of child widgets with their class names.""" + class_name = widget.__class__.__name__ + object_name = widget.objectName() + name_str = f" (name: '{object_name}')" if object_name else "" + print(" " * indent + f"- {class_name}{name_str}") + + for child in widget.children(): + if hasattr(child, "children"): + print_widget_hierarchy(child, indent + 4) + + +if __name__ == "__main__": + autogenerate_images() \ No newline at end of file From 63e8387decc123a2a7b04417386a006e38f4c0f7 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Thu, 13 Mar 2025 11:55:04 -0500 Subject: [PATCH 02/17] clean up --- docs/_scripts/autogenerate_gui_images.py | 57 +++++++++++------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index a9986e39d..ae29c1038 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -17,11 +17,19 @@ def autogenerate_images(): viewer.window._qt_window.resize(800, 600) viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) + # Ensure window is active + viewer.window._qt_window.activateWindow() + viewer.window._qt_window.raise_() + app.processEvents() + + viewer.screenshot(str(IMAGES_PATH / "viewer_empty.png"), canvas_only=False) # Add sample data viewer.open_sample(plugin='napari', sample='cells3d') + app.processEvents() # Ensure viewer is fully initialized + viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False) + # Print Qt widget hierarchy - print_qt_attributes(viewer) print_widget_hierarchy(viewer.window._qt_window) # Wait for viewer to fully initialize and render @@ -35,8 +43,8 @@ def capture_elements(viewer): # Main components - using the hierarchy you provided components = { "welcome_widget": find_widget_by_class(viewer.window._qt_window, "QtWelcomeWidget"), - "console": find_widget_by_class(viewer.window._qt_window, "QtConsole"), - "console_dock": find_widget_by_name(viewer.window._qt_window, "console"), + + "console_dock": find_widget_by_name(viewer.window._qt_window, "console"), # TODO: this was working? "dimension_slider": find_widget_by_class(viewer.window._qt_window, "QtDims"), #QtDimSliderWidget @@ -95,6 +103,10 @@ def grab_menu(): QTimer.singleShot(300, grab_menu) +def close_all(viewer): + viewer.close() + QTimer.singleShot(100, lambda: get_qapp().quit()) + def find_widget_by_name(parent, name): """Find a widget by its object name.""" if parent.objectName() == name: @@ -113,6 +125,9 @@ def find_widget_by_name(parent, name): def find_widget_by_class(parent, class_name): """Find a child widget by its class name.""" + if parent.__class__.__name__ == class_name: + return parent + for child in parent.children(): if child.__class__.__name__ == class_name: return child @@ -124,35 +139,13 @@ def find_widget_by_class(parent, class_name): return None -def close_all(viewer): - print("Closing viewer") - viewer.close() - QTimer.singleShot(100, lambda: get_qapp().quit()) -def print_qt_attributes(viewer): - """Print important Qt-related attributes of the napari viewer.""" - qt_window = viewer.window._qt_window - qt_viewer = viewer.window.qt_viewer - - print("\nQt Window Attributes:") - for attr in dir(qt_window): - if not attr.startswith('_') and hasattr(getattr(qt_window, attr), 'objectName'): - obj = getattr(qt_window, attr) - name = obj.objectName() - print(f" {attr}: {obj.__class__.__name__}{f' (name: {name})' if name else ''}") - - print("\nQt Viewer Attributes:") - for attr in dir(qt_viewer): - if not attr.startswith('_') and not callable(getattr(qt_viewer, attr)): - try: - obj = getattr(qt_viewer, attr) - class_name = obj.__class__.__name__ - print(f" {attr}: {class_name}") - except: - pass - -def print_widget_hierarchy(widget, indent=0): - """Print a hierarchy of child widgets with their class names.""" +def print_widget_hierarchy(widget, indent=0, max_depth=None): + """Print a hierarchy of child widgets with their class names and object names.""" + + if max_depth is not None and indent > max_depth: + return + class_name = widget.__class__.__name__ object_name = widget.objectName() name_str = f" (name: '{object_name}')" if object_name else "" @@ -160,7 +153,7 @@ def print_widget_hierarchy(widget, indent=0): for child in widget.children(): if hasattr(child, "children"): - print_widget_hierarchy(child, indent + 4) + print_widget_hierarchy(child, indent + 4, max_depth) if __name__ == "__main__": From 11a111a571ab0907835c665de5ad3c1ad7b3d34a Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Thu, 13 Mar 2025 12:39:15 -0500 Subject: [PATCH 03/17] barely working popups skeleton --- docs/_scripts/autogenerate_popups.py | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/_scripts/autogenerate_popups.py diff --git a/docs/_scripts/autogenerate_popups.py b/docs/_scripts/autogenerate_popups.py new file mode 100644 index 000000000..e0a5d99a9 --- /dev/null +++ b/docs/_scripts/autogenerate_popups.py @@ -0,0 +1,97 @@ +""" +Minimalist script to capture napari button popups. +""" +from pathlib import Path +from qtpy.QtCore import QPoint, QTimer, Qt +from qtpy.QtWidgets import QApplication +from napari._qt.qt_event_loop import get_qapp +from napari._qt.qt_resources import get_stylesheet +from napari._qt.dialogs.qt_modal import QtPopup +import napari + +# Define paths +IMAGES_PATH = Path(__file__).resolve().parent.parent / "images" / "_autogenerated" / "popups" +IMAGES_PATH.mkdir(parents=True, exist_ok=True) + +def capture_popups(): + """Capture button popups from napari.""" + app = get_qapp() + + # Create viewer and add sample data + print("Setting up viewer...") + viewer = napari.Viewer(show=True) + viewer.window._qt_window.resize(800, 600) + viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) + viewer.open_sample(plugin='napari', sample='cells3d') + + # Wait for viewer to initialize, then start capturing + QTimer.singleShot(2000, lambda: capture_ndisplay_popup(viewer)) + app.exec_() + +def capture_ndisplay_popup(viewer): + """Capture the ndisplay popup.""" + print("Capturing ndisplay popup...") + + # Set to 3D mode for full controls + viewer.dims.ndisplay = 3 + get_qapp().processEvents() + + # Find viewer buttons widget + viewer_buttons = next(w for w in viewer.window._qt_window.findChildren(object) + if w.__class__.__name__ == "QtViewerButtons") + + # Ensure button responds to context menu + viewer_buttons.ndisplayButton.setContextMenuPolicy(Qt.CustomContextMenu) + + # Trigger and capture popup + viewer_buttons.ndisplayButton.customContextMenuRequested.emit(QPoint()) + QTimer.singleShot(800, lambda: save_popup("ndisplay_popup", + lambda: capture_grid_popup(viewer, viewer_buttons))) + +def capture_grid_popup(viewer, viewer_buttons): + """Capture the grid view popup.""" + print("Capturing grid view popup...") + + # Ensure button responds to context menu + viewer_buttons.gridViewButton.setContextMenuPolicy(Qt.CustomContextMenu) + + # Trigger and capture popup + viewer_buttons.gridViewButton.customContextMenuRequested.emit(QPoint()) + QTimer.singleShot(800, lambda: save_popup("grid_view_popup", + lambda: capture_roll_popup(viewer, viewer_buttons))) + +def capture_roll_popup(viewer, viewer_buttons): + """Capture the roll dims popup.""" + print("Capturing roll dims popup...") + + # Ensure button responds to context menu + viewer_buttons.rollDimsButton.setContextMenuPolicy(Qt.CustomContextMenu) + + # Trigger and capture popup + viewer_buttons.rollDimsButton.customContextMenuRequested.emit(QPoint()) + QTimer.singleShot(800, lambda: save_popup("roll_dims_popup", + lambda: close_viewer(viewer))) + +def save_popup(name, next_function): + """Save the current popup and call the next function.""" + # Find the popup + popup = next((w for w in QApplication.topLevelWidgets() + if isinstance(w, QtPopup)), None) + + if popup: + print(f"Saving {name} popup...") + get_qapp().processEvents() + popup.grab().save(str(IMAGES_PATH / f"{name}.png")) + popup.close() + + # Call the next function + QTimer.singleShot(500, next_function) + +def close_viewer(viewer): + """Close the viewer and exit.""" + print("All popups captured, exiting...") + viewer.close() + QTimer.singleShot(200, lambda: get_qapp().quit()) + +if __name__ == "__main__": + capture_popups() \ No newline at end of file From fe5f1b771ea7c8607840f656abe020750a373fa0 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Thu, 13 Mar 2025 12:55:15 -0500 Subject: [PATCH 04/17] im lost this popup sometimes works but usually doesn't --- docs/_scripts/autogenerate_popups.py | 103 +++++++++++++-------------- 1 file changed, 49 insertions(+), 54 deletions(-) diff --git a/docs/_scripts/autogenerate_popups.py b/docs/_scripts/autogenerate_popups.py index e0a5d99a9..54860eb9f 100644 --- a/docs/_scripts/autogenerate_popups.py +++ b/docs/_scripts/autogenerate_popups.py @@ -1,11 +1,10 @@ """ -Minimalist script to capture napari button popups. +Ultra-simple script to capture napari button popups using the same approach as napari's tests. """ from pathlib import Path -from qtpy.QtCore import QPoint, QTimer, Qt +from qtpy.QtCore import QPoint, QTimer from qtpy.QtWidgets import QApplication from napari._qt.qt_event_loop import get_qapp -from napari._qt.qt_resources import get_stylesheet from napari._qt.dialogs.qt_modal import QtPopup import napari @@ -14,82 +13,78 @@ IMAGES_PATH.mkdir(parents=True, exist_ok=True) def capture_popups(): - """Capture button popups from napari.""" + """Capture napari button popups.""" app = get_qapp() # Create viewer and add sample data - print("Setting up viewer...") viewer = napari.Viewer(show=True) viewer.window._qt_window.resize(800, 600) - viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) viewer.open_sample(plugin='napari', sample='cells3d') - # Wait for viewer to initialize, then start capturing - QTimer.singleShot(2000, lambda: capture_ndisplay_popup(viewer)) + # Wait for viewer to initialize + QTimer.singleShot(2000, lambda: start_capture(viewer)) app.exec_() -def capture_ndisplay_popup(viewer): - """Capture the ndisplay popup.""" - print("Capturing ndisplay popup...") +def start_capture(viewer): + """Start the popup capture sequence.""" + # Get the viewer buttons directly - like in the tests + for widget in viewer.window._qt_window.findChildren(object): + if widget.__class__.__name__ == "QtViewerButtons": + viewer_buttons = widget + break - # Set to 3D mode for full controls + # First, set 3D mode for ndisplay popup viewer.dims.ndisplay = 3 get_qapp().processEvents() - # Find viewer buttons widget - viewer_buttons = next(w for w in viewer.window._qt_window.findChildren(object) - if w.__class__.__name__ == "QtViewerButtons") - - # Ensure button responds to context menu - viewer_buttons.ndisplayButton.setContextMenuPolicy(Qt.CustomContextMenu) - - # Trigger and capture popup - viewer_buttons.ndisplayButton.customContextMenuRequested.emit(QPoint()) - QTimer.singleShot(800, lambda: save_popup("ndisplay_popup", - lambda: capture_grid_popup(viewer, viewer_buttons))) - -def capture_grid_popup(viewer, viewer_buttons): - """Capture the grid view popup.""" - print("Capturing grid view popup...") - - # Ensure button responds to context menu - viewer_buttons.gridViewButton.setContextMenuPolicy(Qt.CustomContextMenu) - - # Trigger and capture popup - viewer_buttons.gridViewButton.customContextMenuRequested.emit(QPoint()) - QTimer.singleShot(800, lambda: save_popup("grid_view_popup", - lambda: capture_roll_popup(viewer, viewer_buttons))) + # Schedule the three popups in sequence with delays + # First: ndisplay popup + QTimer.singleShot(100, lambda: trigger_popup( + viewer_buttons.ndisplayButton, + "ndisplay_popup", + lambda: trigger_popup( + viewer_buttons.gridViewButton, + "grid_popup", + lambda: cleanup(viewer) + ) + )) -def capture_roll_popup(viewer, viewer_buttons): - """Capture the roll dims popup.""" - print("Capturing roll dims popup...") +def trigger_popup(button, name, next_func): + """Trigger a popup by emitting a context menu request, then capture it.""" + print(f"Triggering {name}...") - # Ensure button responds to context menu - viewer_buttons.rollDimsButton.setContextMenuPolicy(Qt.CustomContextMenu) + # Trigger the popup - same as in the tests + button.customContextMenuRequested.emit(QPoint()) - # Trigger and capture popup - viewer_buttons.rollDimsButton.customContextMenuRequested.emit(QPoint()) - QTimer.singleShot(800, lambda: save_popup("roll_dims_popup", - lambda: close_viewer(viewer))) + # Give popup time to appear + QTimer.singleShot(800, lambda: capture_popup(name, next_func)) -def save_popup(name, next_function): - """Save the current popup and call the next function.""" +def capture_popup(name, next_func): + """Capture the currently visible popup.""" # Find the popup - popup = next((w for w in QApplication.topLevelWidgets() - if isinstance(w, QtPopup)), None) + popup = None + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QtPopup): + popup = widget + break if popup: - print(f"Saving {name} popup...") + print(f"Found popup, capturing {name}...") get_qapp().processEvents() + + # Capture and save popup.grab().save(str(IMAGES_PATH / f"{name}.png")) popup.close() + print(f"Saved {name}") + else: + print(f"No popup found for {name}") - # Call the next function - QTimer.singleShot(500, next_function) + # Call the next function after a delay + QTimer.singleShot(500, next_func) -def close_viewer(viewer): - """Close the viewer and exit.""" - print("All popups captured, exiting...") +def cleanup(viewer): + """Close the viewer and quit.""" + print("All captures complete") viewer.close() QTimer.singleShot(200, lambda: get_qapp().quit()) From 8072f0596eed0a2233b9840b031b27c541f455f4 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Thu, 13 Mar 2025 21:27:40 -0500 Subject: [PATCH 05/17] cleaner working popups --- docs/_scripts/autogenerate_gui_images.py | 70 +++++++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index ae29c1038..f9cffa7f8 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -1,8 +1,10 @@ from pathlib import Path -from qtpy.QtCore import QTimer +from qtpy.QtCore import QTimer, QPoint from napari._qt.qt_event_loop import get_qapp from napari._qt.qt_resources import get_stylesheet +from napari._qt.dialogs.qt_modal import QtPopup +from qtpy.QtWidgets import QApplication import napari DOCS = REPO_ROOT_PATH = Path(__file__).resolve().parent.parent @@ -30,7 +32,7 @@ def autogenerate_images(): viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False) # Print Qt widget hierarchy - print_widget_hierarchy(viewer.window._qt_window) + # print_widget_hierarchy(viewer.window._qt_window) # Wait for viewer to fully initialize and render QTimer.singleShot(2000, lambda: capture_elements(viewer)) @@ -88,9 +90,71 @@ def capture_elements(viewer): except Exception as e: print(f"Error capturing {name}: {e}") + QTimer.singleShot(500, lambda: capture_viewer_button_popups(viewer)) + # TODO: This needs to be done at the end of capture_elements and not autogenerate_images, why? - QTimer.singleShot(2000, lambda: close_all(viewer)) + # QTimer.singleShot(100, lambda: close_all(viewer)) + +def capture_viewer_button_popups(viewer): + """Capture popups that appear when clicking on viewer buttons.""" + print("Capturing viewer button popups") + viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") + + # First capture ndisplay popup, when done it will trigger grid view + capture_ndisplay_popup(viewer, viewer_buttons) + +def capture_ndisplay_popup(viewer, viewer_buttons): + """Capture the ndisplay button popup.""" + print("Capturing ndisplay popup") + + # Switch to 3D mode to see all perspective controls + viewer.dims.ndisplay = 3 + get_qapp().processEvents() + + button = viewer_buttons.ndisplayButton + button.customContextMenuRequested.emit(QPoint()) + + # Wait longer for the popup to appear + QTimer.singleShot(800, lambda: find_and_capture_popup("ndisplay_popup", + lambda: capture_grid_view_popup(viewer, viewer_buttons))) + +def capture_grid_view_popup(viewer, viewer_buttons): + """Capture the grid view button popup.""" + + button = viewer_buttons.gridViewButton + button.customContextMenuRequested.emit(QPoint()) + + # Wait longer for the popup to appear and then close the app when done + QTimer.singleShot(800, lambda: find_and_capture_popup("grid_view_popup", + lambda: QTimer.singleShot(500, lambda: close_all(viewer)))) +def find_and_capture_popup(name, next_function=None): + """Find any open QtPopup widgets and capture them.""" + popup = None + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QtPopup): + popup = widget + break + + if popup: + try: + print(f"Found popup, capturing {name}...") + get_qapp().processEvents() + + pixmap = popup.grab() + pixmap.save(str(IMAGES_PATH / f"{name}.png")) + popup.close() + print(f"Captured and closed {name}") + except Exception as e: + print(f"Error grabbing popup {name}: {e}") + else: + print(f"No popup found for {name}") + + # Call the next function in sequence if provided + if next_function: + QTimer.singleShot(500, next_function) + + def show_menu_for_screenshot(menu, name): """Show a menu and take screenshot of it.""" menu.popup(menu.parent().mapToGlobal(menu.pos())) From d0e69d4b81a69b8a8b0694e0f52774e8abe453c8 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Thu, 13 Mar 2025 21:41:02 -0500 Subject: [PATCH 06/17] gui includes both viewer and popups -- but popups sometimes don't work --- docs/_scripts/autogenerate_gui_images.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index f9cffa7f8..d67b17736 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -31,8 +31,12 @@ def autogenerate_images(): app.processEvents() # Ensure viewer is fully initialized viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False) + # Open the console + viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") + viewer_buttons.consoleButton.click() + # Print Qt widget hierarchy - # print_widget_hierarchy(viewer.window._qt_window) + print_widget_hierarchy(viewer.window._qt_window) # Wait for viewer to fully initialize and render QTimer.singleShot(2000, lambda: capture_elements(viewer)) @@ -110,14 +114,25 @@ def capture_ndisplay_popup(viewer, viewer_buttons): # Switch to 3D mode to see all perspective controls viewer.dims.ndisplay = 3 get_qapp().processEvents() + + close_existing_popups() + # viewer_buttons.ndisplayButton.click() button = viewer_buttons.ndisplayButton button.customContextMenuRequested.emit(QPoint()) # Wait longer for the popup to appear - QTimer.singleShot(800, lambda: find_and_capture_popup("ndisplay_popup", + QTimer.singleShot(500, lambda: find_and_capture_popup("ndisplay_popup", lambda: capture_grid_view_popup(viewer, viewer_buttons))) +def close_existing_popups(): + """Close any existing popups.""" + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QtPopup): + widget.close() + + get_qapp().processEvents() + def capture_grid_view_popup(viewer, viewer_buttons): """Capture the grid view button popup.""" @@ -125,7 +140,7 @@ def capture_grid_view_popup(viewer, viewer_buttons): button.customContextMenuRequested.emit(QPoint()) # Wait longer for the popup to appear and then close the app when done - QTimer.singleShot(800, lambda: find_and_capture_popup("grid_view_popup", + QTimer.singleShot(500, lambda: find_and_capture_popup("grid_view_popup", lambda: QTimer.singleShot(500, lambda: close_all(viewer)))) def find_and_capture_popup(name, next_function=None): From 456cdb8416b4b51ae3ed75f9159129b4900c0520 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Thu, 13 Mar 2025 22:42:14 -0500 Subject: [PATCH 07/17] seems to work less but code is better --- docs/_scripts/autogenerate_gui_images.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index d67b17736..954119233 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -109,12 +109,9 @@ def capture_viewer_button_popups(viewer): def capture_ndisplay_popup(viewer, viewer_buttons): """Capture the ndisplay button popup.""" - print("Capturing ndisplay popup") # Switch to 3D mode to see all perspective controls - viewer.dims.ndisplay = 3 - get_qapp().processEvents() - + viewer.dims.ndisplay = 3 close_existing_popups() # viewer_buttons.ndisplayButton.click() @@ -125,16 +122,9 @@ def capture_ndisplay_popup(viewer, viewer_buttons): QTimer.singleShot(500, lambda: find_and_capture_popup("ndisplay_popup", lambda: capture_grid_view_popup(viewer, viewer_buttons))) -def close_existing_popups(): - """Close any existing popups.""" - for widget in QApplication.topLevelWidgets(): - if isinstance(widget, QtPopup): - widget.close() - - get_qapp().processEvents() - def capture_grid_view_popup(viewer, viewer_buttons): """Capture the grid view button popup.""" + close_existing_popups() button = viewer_buttons.gridViewButton button.customContextMenuRequested.emit(QPoint()) @@ -186,6 +176,14 @@ def close_all(viewer): viewer.close() QTimer.singleShot(100, lambda: get_qapp().quit()) +def close_existing_popups(): + """Close any existing popups.""" + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QtPopup): + widget.close() + + get_qapp().processEvents() + def find_widget_by_name(parent, name): """Find a widget by its object name.""" if parent.objectName() == name: From 91cde7a7325c8175eefa2a2bb1ebda1dc8b4dff4 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Fri, 14 Mar 2025 09:57:21 -0500 Subject: [PATCH 08/17] remove popups file -- outdated --- docs/_scripts/autogenerate_gui_images.py | 2 +- docs/_scripts/autogenerate_popups.py | 92 ------------------------ 2 files changed, 1 insertion(+), 93 deletions(-) delete mode 100644 docs/_scripts/autogenerate_popups.py diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index 954119233..214da6429 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -16,7 +16,7 @@ def autogenerate_images(): # Create viewer with visible window viewer = napari.Viewer(show=True) - viewer.window._qt_window.resize(800, 600) + viewer.window._qt_window.resize(1000, 800) viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) # Ensure window is active diff --git a/docs/_scripts/autogenerate_popups.py b/docs/_scripts/autogenerate_popups.py deleted file mode 100644 index 54860eb9f..000000000 --- a/docs/_scripts/autogenerate_popups.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Ultra-simple script to capture napari button popups using the same approach as napari's tests. -""" -from pathlib import Path -from qtpy.QtCore import QPoint, QTimer -from qtpy.QtWidgets import QApplication -from napari._qt.qt_event_loop import get_qapp -from napari._qt.dialogs.qt_modal import QtPopup -import napari - -# Define paths -IMAGES_PATH = Path(__file__).resolve().parent.parent / "images" / "_autogenerated" / "popups" -IMAGES_PATH.mkdir(parents=True, exist_ok=True) - -def capture_popups(): - """Capture napari button popups.""" - app = get_qapp() - - # Create viewer and add sample data - viewer = napari.Viewer(show=True) - viewer.window._qt_window.resize(800, 600) - viewer.open_sample(plugin='napari', sample='cells3d') - - # Wait for viewer to initialize - QTimer.singleShot(2000, lambda: start_capture(viewer)) - app.exec_() - -def start_capture(viewer): - """Start the popup capture sequence.""" - # Get the viewer buttons directly - like in the tests - for widget in viewer.window._qt_window.findChildren(object): - if widget.__class__.__name__ == "QtViewerButtons": - viewer_buttons = widget - break - - # First, set 3D mode for ndisplay popup - viewer.dims.ndisplay = 3 - get_qapp().processEvents() - - # Schedule the three popups in sequence with delays - # First: ndisplay popup - QTimer.singleShot(100, lambda: trigger_popup( - viewer_buttons.ndisplayButton, - "ndisplay_popup", - lambda: trigger_popup( - viewer_buttons.gridViewButton, - "grid_popup", - lambda: cleanup(viewer) - ) - )) - -def trigger_popup(button, name, next_func): - """Trigger a popup by emitting a context menu request, then capture it.""" - print(f"Triggering {name}...") - - # Trigger the popup - same as in the tests - button.customContextMenuRequested.emit(QPoint()) - - # Give popup time to appear - QTimer.singleShot(800, lambda: capture_popup(name, next_func)) - -def capture_popup(name, next_func): - """Capture the currently visible popup.""" - # Find the popup - popup = None - for widget in QApplication.topLevelWidgets(): - if isinstance(widget, QtPopup): - popup = widget - break - - if popup: - print(f"Found popup, capturing {name}...") - get_qapp().processEvents() - - # Capture and save - popup.grab().save(str(IMAGES_PATH / f"{name}.png")) - popup.close() - print(f"Saved {name}") - else: - print(f"No popup found for {name}") - - # Call the next function after a delay - QTimer.singleShot(500, next_func) - -def cleanup(viewer): - """Close the viewer and quit.""" - print("All captures complete") - viewer.close() - QTimer.singleShot(200, lambda: get_qapp().quit()) - -if __name__ == "__main__": - capture_popups() \ No newline at end of file From 31e81d0d60c84a103057d89654d60795d24c7adc Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:03:11 -0500 Subject: [PATCH 09/17] clean up widget and menu popup capturing --- docs/_scripts/autogenerate_gui_images.py | 106 +++++++++++++++-------- 1 file changed, 68 insertions(+), 38 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index 214da6429..ab51ed5db 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -1,15 +1,22 @@ from pathlib import Path from qtpy.QtCore import QTimer, QPoint +import napari + from napari._qt.qt_event_loop import get_qapp from napari._qt.qt_resources import get_stylesheet from napari._qt.dialogs.qt_modal import QtPopup from qtpy.QtWidgets import QApplication -import napari DOCS = REPO_ROOT_PATH = Path(__file__).resolve().parent.parent IMAGES_PATH = DOCS / "images" / "_autogenerated" IMAGES_PATH.mkdir(parents=True, exist_ok=True) +WIDGETS_PATH = IMAGES_PATH / "widgets" +WIDGETS_PATH.mkdir(parents=True, exist_ok=True) +MENUS_PATH = IMAGES_PATH / "menus" +MENUS_PATH.mkdir(parents=True, exist_ok=True) +POPUPS_PATH = IMAGES_PATH / "popups" +POPUPS_PATH.mkdir(parents=True, exist_ok=True) def autogenerate_images(): app = get_qapp() @@ -45,54 +52,64 @@ def autogenerate_images(): def capture_elements(viewer): """Capture specific UI elements based on the widget hierarchy.""" + qt_window = viewer.window._qt_window # Main components - using the hierarchy you provided - components = { - "welcome_widget": find_widget_by_class(viewer.window._qt_window, "QtWelcomeWidget"), + viewer_components = { + "welcome_widget": find_widget_by_class(qt_window, "QtWelcomeWidget"), - "console_dock": find_widget_by_name(viewer.window._qt_window, "console"), # TODO: this was working? + "console_dock": find_widget_by_name(qt_window, "console"), - "dimension_slider": find_widget_by_class(viewer.window._qt_window, "QtDims"), #QtDimSliderWidget + "dimension_slider": find_widget_by_class(qt_window, "QtDims"), # Layer list components - "layer_list_dock": find_widget_by_name(viewer.window._qt_window, "layer list"), - "layer_buttons": find_widget_by_class(viewer.window._qt_window, "QtLayerButtons"), - "layer_list": find_widget_by_class(viewer.window._qt_window, "QtLayerList"), - "viewer_buttons": find_widget_by_class(viewer.window._qt_window, "QtViewerButtons"), + "layer_list_dock": find_widget_by_name(qt_window, "layer list"), + "layer_buttons": find_widget_by_class(qt_window, "QtLayerButtons"), + "layer_list": find_widget_by_class(qt_window, "QtLayerList"), + "viewer_buttons": find_widget_by_class(qt_window, "QtViewerButtons"), # Layer controls - "layer_controls_dock": find_widget_by_name(viewer.window._qt_window, "layer controls"), + "layer_controls_dock": find_widget_by_name(qt_window, "layer controls"), # TODO: mouse over part of the image to show intensity stuff "status_bar": viewer.window._status_bar, - - # Menus - "menu_bar": find_widget_by_class(viewer.window._qt_window, "QMenuBar"), - "file_menu": find_widget_by_name(viewer.window._qt_window, "napari/file"), - "samples_menu": find_widget_by_name(viewer.window._qt_window, "napari/file/samples/napari"), - "view_menu": find_widget_by_name(viewer.window._qt_window, "napari/view"), - "layers_menu": find_widget_by_name(viewer.window._qt_window, "napari/layers"), - "plugins_menu": find_widget_by_name(viewer.window._qt_window, "napari/plugins"), - "window_menu": find_widget_by_name(viewer.window._qt_window, "napari/window"), - "help_menu": find_widget_by_name(viewer.window._qt_window, "napari/help"), } + menu_components = { + "file_menu": find_widget_by_name(qt_window, "napari/file"), + "samples_menu": find_widget_by_name(qt_window, "napari/file/samples/napari"), + "view_menu": find_widget_by_name(qt_window, "napari/view"), + "layers_menu": find_widget_by_name(qt_window, "napari/layers"), + "plugins_menu": find_widget_by_name(qt_window, "napari/plugins"), + "window_menu": find_widget_by_name(qt_window, "napari/window"), + "help_menu": find_widget_by_name(qt_window, "napari/help"), + } + + + viewer_buttons = viewer_components["viewer_buttons"] + + popups_configs = [ + { + "name": "ndisplay_2D_popup", + "prep": lambda: setattr(viewer.dims, "ndisplay", 2), + "button": viewer_buttons.ndisplayButton, + }, + { + "name": "ndisplay_3D_popup", + "prep": lambda: setattr(viewer.dims, "ndisplay", 3), + "button": viewer_buttons.ndisplayButton, + } + ] + # Capture each component - for name, widget in components.items(): - try: - if widget is None: - print(f"Could not find {name}") - continue + for name, widget in viewer_components.items(): + capture_widget(widget, name) - # For menus, need to show them first - if name.endswith('_menu'): - show_menu_for_screenshot(widget, name) - continue - - pixmap = widget.grab() - pixmap.save(str(IMAGES_PATH / f"{name}.png")) - except Exception as e: - print(f"Error capturing {name}: {e}") + for name, menu in menu_components.items(): + capture_menu(menu, name) + + for config in popups_configs: + pass QTimer.singleShot(500, lambda: capture_viewer_button_popups(viewer)) @@ -147,7 +164,7 @@ def find_and_capture_popup(name, next_function=None): get_qapp().processEvents() pixmap = popup.grab() - pixmap.save(str(IMAGES_PATH / f"{name}.png")) + pixmap.save(str(POPUPS_PATH / f"{name}.png")) popup.close() print(f"Captured and closed {name}") except Exception as e: @@ -159,18 +176,31 @@ def find_and_capture_popup(name, next_function=None): if next_function: QTimer.singleShot(500, next_function) - -def show_menu_for_screenshot(menu, name): + +def capture_widget(widget, name): + """Capture a widget and save it to a file.""" + if widget is None: + return print(f"Could not find {name}") + + pixmap = widget.grab() + pixmap.save(str(WIDGETS_PATH / f"{name}.png")) + return + +def capture_menu(menu, name): """Show a menu and take screenshot of it.""" + if menu is None: + return print(f"Could not find menu {name}") + menu.popup(menu.parent().mapToGlobal(menu.pos())) # Give menu time to appear def grab_menu(): pixmap = menu.grab() - pixmap.save(str(IMAGES_PATH / f"{name}.png")) + pixmap.save(str(MENUS_PATH / f"{name}.png")) menu.hide() QTimer.singleShot(300, grab_menu) + return def close_all(viewer): viewer.close() From b2df72799414db6772b1e07c245013f3cf584be6 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:37:35 -0500 Subject: [PATCH 10/17] got popups working consistently --- docs/_scripts/autogenerate_gui_images.py | 81 ++++++------------------ 1 file changed, 21 insertions(+), 60 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index ab51ed5db..d2209a915 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -46,7 +46,7 @@ def autogenerate_images(): print_widget_hierarchy(viewer.window._qt_window) # Wait for viewer to fully initialize and render - QTimer.singleShot(2000, lambda: capture_elements(viewer)) + QTimer.singleShot(200, lambda: capture_elements(viewer)) app.exec_() @@ -109,73 +109,34 @@ def capture_elements(viewer): capture_menu(menu, name) for config in popups_configs: - pass + capture_popups(config) - QTimer.singleShot(500, lambda: capture_viewer_button_popups(viewer)) - - # TODO: This needs to be done at the end of capture_elements and not autogenerate_images, why? - # QTimer.singleShot(100, lambda: close_all(viewer)) + QTimer.singleShot(100, lambda: close_all(viewer)) -def capture_viewer_button_popups(viewer): +def capture_popups(config): """Capture popups that appear when clicking on viewer buttons.""" - print("Capturing viewer button popups") - viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") - - # First capture ndisplay popup, when done it will trigger grid view - capture_ndisplay_popup(viewer, viewer_buttons) - -def capture_ndisplay_popup(viewer, viewer_buttons): - """Capture the ndisplay button popup.""" - - # Switch to 3D mode to see all perspective controls - viewer.dims.ndisplay = 3 - close_existing_popups() - - # viewer_buttons.ndisplayButton.click() - button = viewer_buttons.ndisplayButton - button.customContextMenuRequested.emit(QPoint()) - - # Wait longer for the popup to appear - QTimer.singleShot(500, lambda: find_and_capture_popup("ndisplay_popup", - lambda: capture_grid_view_popup(viewer, viewer_buttons))) - -def capture_grid_view_popup(viewer, viewer_buttons): - """Capture the grid view button popup.""" + app = get_qapp() close_existing_popups() - button = viewer_buttons.gridViewButton - button.customContextMenuRequested.emit(QPoint()) - - # Wait longer for the popup to appear and then close the app when done - QTimer.singleShot(500, lambda: find_and_capture_popup("grid_view_popup", - lambda: QTimer.singleShot(500, lambda: close_all(viewer)))) - -def find_and_capture_popup(name, next_function=None): - """Find any open QtPopup widgets and capture them.""" - popup = None - for widget in QApplication.topLevelWidgets(): - if isinstance(widget, QtPopup): - popup = widget - break + config["prep"]() + app.processEvents() + config["button"].customContextMenuRequested.emit(QPoint()) + app.processEvents() + popups = [w for w in QApplication.topLevelWidgets() if isinstance(w, QtPopup) and w.isVisible()] - if popup: - try: - print(f"Found popup, capturing {name}...") - get_qapp().processEvents() - - pixmap = popup.grab() - pixmap.save(str(POPUPS_PATH / f"{name}.png")) - popup.close() - print(f"Captured and closed {name}") - except Exception as e: - print(f"Error grabbing popup {name}: {e}") - else: - print(f"No popup found for {name}") + if not popups: + return print(f"No popup found for {config['name']}") + popup = popups[-1] - # Call the next function in sequence if provided - if next_function: - QTimer.singleShot(500, next_function) + app.processEvents() + + def grab_popup(): + pixmap = popup.grab() + pixmap.save(str(POPUPS_PATH / f"{config['name']}.png")) + popup.close() + app.processEvents() + QTimer.singleShot(300, grab_popup) def capture_widget(widget, name): """Capture a widget and save it to a file.""" From 70e7e038116366020f517290f69fedd720ffc198 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:51:41 -0500 Subject: [PATCH 11/17] add more popups, clean up code more --- docs/_scripts/autogenerate_gui_images.py | 50 ++++++++++++++---------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index d2209a915..dfb555828 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -43,7 +43,7 @@ def autogenerate_images(): viewer_buttons.consoleButton.click() # Print Qt widget hierarchy - print_widget_hierarchy(viewer.window._qt_window) + # print_widget_hierarchy(viewer.window._qt_window) # Wait for viewer to fully initialize and render QTimer.singleShot(200, lambda: capture_elements(viewer)) @@ -75,6 +75,10 @@ def capture_elements(viewer): "status_bar": viewer.window._status_bar, } + # Capture each component + for name, widget in viewer_components.items(): + capture_widget(widget, name) + menu_components = { "file_menu": find_widget_by_name(qt_window, "napari/file"), "samples_menu": find_widget_by_name(qt_window, "napari/file/samples/napari"), @@ -84,8 +88,11 @@ def capture_elements(viewer): "window_menu": find_widget_by_name(qt_window, "napari/window"), "help_menu": find_widget_by_name(qt_window, "napari/help"), } - - + + for name, menu in menu_components.items(): + capture_menu(menu, name) + + viewer_buttons = viewer_components["viewer_buttons"] popups_configs = [ @@ -94,20 +101,23 @@ def capture_elements(viewer): "prep": lambda: setattr(viewer.dims, "ndisplay", 2), "button": viewer_buttons.ndisplayButton, }, + { + "name": "roll_dims_popup", + "prep": lambda: setattr(viewer.dims, "ndisplay", 2), + "button": viewer_buttons.rollDimsButton, + }, { "name": "ndisplay_3D_popup", "prep": lambda: setattr(viewer.dims, "ndisplay", 3), "button": viewer_buttons.ndisplayButton, + }, + { + "name": "grid_popup", + "prep": None, + "button": viewer_buttons.gridViewButton, } ] - - # Capture each component - for name, widget in viewer_components.items(): - capture_widget(widget, name) - - for name, menu in menu_components.items(): - capture_menu(menu, name) - + for config in popups_configs: capture_popups(config) @@ -118,7 +128,9 @@ def capture_popups(config): app = get_qapp() close_existing_popups() - config["prep"]() + if config["prep"] is not None: + config["prep"]() + app.processEvents() config["button"].customContextMenuRequested.emit(QPoint()) app.processEvents() @@ -126,17 +138,15 @@ def capture_popups(config): if not popups: return print(f"No popup found for {config['name']}") - popup = popups[-1] + + popup = popups[-1] # grab the most recent popup, just in case app.processEvents() - def grab_popup(): - pixmap = popup.grab() - pixmap.save(str(POPUPS_PATH / f"{config['name']}.png")) - popup.close() - app.processEvents() - - QTimer.singleShot(300, grab_popup) + pixmap = popup.grab() + pixmap.save(str(POPUPS_PATH / f"{config['name']}.png")) + popup.close() + app.processEvents() def capture_widget(widget, name): """Capture a widget and save it to a file.""" From 0995949775322ada08005b4a574af1c4022a881b Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:55:54 -0500 Subject: [PATCH 12/17] speed up slightly --- docs/_scripts/autogenerate_gui_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index dfb555828..c247a6f57 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -170,7 +170,7 @@ def grab_menu(): pixmap.save(str(MENUS_PATH / f"{name}.png")) menu.hide() - QTimer.singleShot(300, grab_menu) + QTimer.singleShot(50, grab_menu) return def close_all(viewer): From 3d8aec988abe409ca2b77531383a08f05efedcf1 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:59:59 -0500 Subject: [PATCH 13/17] lengthen menu QTimer so as not to cause issue with popup generation --- docs/_scripts/autogenerate_gui_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index c247a6f57..dfb555828 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -170,7 +170,7 @@ def grab_menu(): pixmap.save(str(MENUS_PATH / f"{name}.png")) menu.hide() - QTimer.singleShot(50, grab_menu) + QTimer.singleShot(300, grab_menu) return def close_all(viewer): From 70b3373dc6e6c7d3c89cad6c3f33f9d9bea903ea Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Wed, 19 Mar 2025 21:58:36 -0500 Subject: [PATCH 14/17] Refactor autogenerate_images function and modularize widget, menu, and popup handling --- docs/_scripts/autogenerate_gui_images.py | 119 +++++++++++------------ 1 file changed, 56 insertions(+), 63 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index dfb555828..604f037c5 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -6,7 +6,8 @@ from napari._qt.qt_event_loop import get_qapp from napari._qt.qt_resources import get_stylesheet from napari._qt.dialogs.qt_modal import QtPopup -from qtpy.QtWidgets import QApplication +from napari._qt.widgets.qt_viewer_buttons import QtViewerButtons +from qtpy.QtWidgets import QApplication, QWidget DOCS = REPO_ROOT_PATH = Path(__file__).resolve().parent.parent IMAGES_PATH = DOCS / "images" / "_autogenerated" @@ -18,44 +19,8 @@ POPUPS_PATH = IMAGES_PATH / "popups" POPUPS_PATH.mkdir(parents=True, exist_ok=True) -def autogenerate_images(): - app = get_qapp() - - # Create viewer with visible window - viewer = napari.Viewer(show=True) - viewer.window._qt_window.resize(1000, 800) - viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) - - # Ensure window is active - viewer.window._qt_window.activateWindow() - viewer.window._qt_window.raise_() - app.processEvents() - - viewer.screenshot(str(IMAGES_PATH / "viewer_empty.png"), canvas_only=False) - # Add sample data - viewer.open_sample(plugin='napari', sample='cells3d') - - app.processEvents() # Ensure viewer is fully initialized - viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False) - - # Open the console - viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") - viewer_buttons.consoleButton.click() - - # Print Qt widget hierarchy - # print_widget_hierarchy(viewer.window._qt_window) - - # Wait for viewer to fully initialize and render - QTimer.singleShot(200, lambda: capture_elements(viewer)) - - app.exec_() - -def capture_elements(viewer): - """Capture specific UI elements based on the widget hierarchy.""" - qt_window = viewer.window._qt_window - - # Main components - using the hierarchy you provided - viewer_components = { +def _get_widget_componenets(qt_window: QWidget) -> dict: + return { "welcome_widget": find_widget_by_class(qt_window, "QtWelcomeWidget"), "console_dock": find_widget_by_name(qt_window, "console"), @@ -72,14 +37,11 @@ def capture_elements(viewer): "layer_controls_dock": find_widget_by_name(qt_window, "layer controls"), # TODO: mouse over part of the image to show intensity stuff - "status_bar": viewer.window._status_bar, + "status_bar": find_widget_by_class(qt_window, "ViewerStatusBar"), } - # Capture each component - for name, widget in viewer_components.items(): - capture_widget(widget, name) - - menu_components = { +def _get_menu_components(qt_window: QWidget) -> dict: + return { "file_menu": find_widget_by_name(qt_window, "napari/file"), "samples_menu": find_widget_by_name(qt_window, "napari/file/samples/napari"), "view_menu": find_widget_by_name(qt_window, "napari/view"), @@ -88,14 +50,12 @@ def capture_elements(viewer): "window_menu": find_widget_by_name(qt_window, "napari/window"), "help_menu": find_widget_by_name(qt_window, "napari/help"), } - - for name, menu in menu_components.items(): - capture_menu(menu, name) - - - viewer_buttons = viewer_components["viewer_buttons"] - popups_configs = [ +def _get_button_popups_configs( + viewer: napari.Viewer, + viewer_buttons: QtViewerButtons +) -> list[dict]: + return [ { "name": "ndisplay_2D_popup", "prep": lambda: setattr(viewer.dims, "ndisplay", 2), @@ -118,10 +78,47 @@ def capture_elements(viewer): } ] - for config in popups_configs: - capture_popups(config) +def autogenerate_images(): + app = get_qapp() + + # Create viewer with visible window + viewer = napari.Viewer(show=True) + viewer.window._qt_window.resize(1000, 800) + viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) + + # Ensure window is active + viewer.window._qt_window.activateWindow() + viewer.window._qt_window.raise_() + app.processEvents() - QTimer.singleShot(100, lambda: close_all(viewer)) + viewer.screenshot(str(IMAGES_PATH / "viewer_empty.png"), canvas_only=False) + viewer.open_sample(plugin='napari', sample='cells3d') + + app.processEvents() # Ensure viewer is fully initialized + viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False) + + # Open the console + viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") + viewer_buttons.consoleButton.click() + app.processEvents() + + # Print Qt widget hierarchy + # print_widget_hierarchy(viewer.window._qt_window) + + widget_componenets = _get_widget_componenets(viewer.window._qt_window) + for name, widget in widget_componenets.items(): + capture_widget(widget, name) + + menu_components = _get_menu_components(viewer.window._qt_window) + for name, menu in menu_components.items(): + capture_menu(menu, name) + + button_popups_configs = _get_button_popups_configs(viewer, viewer_buttons) + for config in button_popups_configs: + capture_popups(config) + + close_all(viewer) + app.exec_() def capture_popups(config): """Capture popups that appear when clicking on viewer buttons.""" @@ -164,18 +161,14 @@ def capture_menu(menu, name): menu.popup(menu.parent().mapToGlobal(menu.pos())) - # Give menu time to appear - def grab_menu(): - pixmap = menu.grab() - pixmap.save(str(MENUS_PATH / f"{name}.png")) - menu.hide() - - QTimer.singleShot(300, grab_menu) + pixmap = menu.grab() + pixmap.save(str(MENUS_PATH / f"{name}.png")) + menu.hide() return def close_all(viewer): viewer.close() - QTimer.singleShot(100, lambda: get_qapp().quit()) + QTimer.singleShot(10, lambda: get_qapp().quit()) def close_existing_popups(): """Close any existing popups.""" From 73c5ce1ab03af317b08172e190ef0dd039792832 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:18:46 -0500 Subject: [PATCH 15/17] add mouse over for status bar --- docs/_scripts/autogenerate_gui_images.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index 604f037c5..ee735de23 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -2,6 +2,7 @@ from qtpy.QtCore import QTimer, QPoint import napari +import time from napari._qt.qt_event_loop import get_qapp from napari._qt.qt_resources import get_stylesheet @@ -83,6 +84,10 @@ def autogenerate_images(): # Create viewer with visible window viewer = napari.Viewer(show=True) + + # Print Qt widget hierarchy + # print_widget_hierarchy(viewer.window._qt_window) + viewer.window._qt_window.resize(1000, 800) viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) @@ -94,16 +99,20 @@ def autogenerate_images(): viewer.screenshot(str(IMAGES_PATH / "viewer_empty.png"), canvas_only=False) viewer.open_sample(plugin='napari', sample='cells3d') + # Mouse over canvas for status bar update + viewer.layers.selection = viewer.layers + viewer.mouse_over_canvas = True + viewer.cursor.position = [25, 50, 120] + viewer.update_status_from_cursor() app.processEvents() # Ensure viewer is fully initialized + viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False) # Open the console viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") viewer_buttons.consoleButton.click() app.processEvents() - - # Print Qt widget hierarchy - # print_widget_hierarchy(viewer.window._qt_window) + widget_componenets = _get_widget_componenets(viewer.window._qt_window) for name, widget in widget_componenets.items(): From e267262b225cd9e5dbc9efd70bc47df441d4d2ad Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:33:05 -0500 Subject: [PATCH 16/17] improve docstrings --- docs/_scripts/autogenerate_gui_images.py | 69 ++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index ee735de23..b7857bee5 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -2,7 +2,6 @@ from qtpy.QtCore import QTimer, QPoint import napari -import time from napari._qt.qt_event_loop import get_qapp from napari._qt.qt_resources import get_stylesheet @@ -21,6 +20,19 @@ POPUPS_PATH.mkdir(parents=True, exist_ok=True) def _get_widget_componenets(qt_window: QWidget) -> dict: + """Get visible widget components from the Qt window. + + Parameters + ---------- + qt_window : QWidget + qt_window of the viewer. + + Returns + ------- + dict + Dictionary with keys corresponding to widget names for saving + and values corresponding to the QWidget itself + """ return { "welcome_widget": find_widget_by_class(qt_window, "QtWelcomeWidget"), @@ -42,6 +54,20 @@ def _get_widget_componenets(qt_window: QWidget) -> dict: } def _get_menu_components(qt_window: QWidget) -> dict: + """Get menu bar components from the Qt window. + + Parameters + ---------- + qt_window : QWidget + qt_window of the viewer. + + Returns + ------- + dict + Dictionary with keys corresponding to menu names for saving + and values corresponding to the menu widget location. + """ + return { "file_menu": find_widget_by_name(qt_window, "napari/file"), "samples_menu": find_widget_by_name(qt_window, "napari/file/samples/napari"), @@ -54,8 +80,29 @@ def _get_menu_components(qt_window: QWidget) -> dict: def _get_button_popups_configs( viewer: napari.Viewer, - viewer_buttons: QtViewerButtons ) -> list[dict]: + """Get configurations for capturing popups that appear when clicking on viewer buttons. + + Parameters + ---------- + viewer : napari.Viewer + + Returns + ------- + list[dict] + List of dictionaries with the following keys: + - name: str + Name of the popup. + - prep: callable + Function to prepare the viewer before opening the popup. + - button: QtViewerButton + Button that opens the popup. + """ + viewer_buttons = find_widget_by_class( + viewer.window._qt_window, + "QtViewerButtons" + ) + return [ { "name": "ndisplay_2D_popup", @@ -80,6 +127,20 @@ def _get_button_popups_configs( ] def autogenerate_images(): + """Autogenerate images of the GUI components. + + This function opens a napari viewer, takes screenshots of the GUI components, + and saves them to the images/_autogenerated folder. + + At first, the viewer is prepped for various states and screenshots are taken + of the whole viewer, with a moused-over sample image. + + Then, the function captures visible widgets, triggers menus, and then captures + right-click button popups. + + Finally, the viewer is closed and the Qt application is executed + to ensure all widgets are properly cleaned up. + """ app = get_qapp() # Create viewer with visible window @@ -112,6 +173,8 @@ def autogenerate_images(): viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") viewer_buttons.consoleButton.click() app.processEvents() + + viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d_console.png"), canvas_only=False) widget_componenets = _get_widget_componenets(viewer.window._qt_window) @@ -122,7 +185,7 @@ def autogenerate_images(): for name, menu in menu_components.items(): capture_menu(menu, name) - button_popups_configs = _get_button_popups_configs(viewer, viewer_buttons) + button_popups_configs = _get_button_popups_configs(viewer) for config in button_popups_configs: capture_popups(config) From c20d526c991af2237f68205f1ccb27182141eb05 Mon Sep 17 00:00:00 2001 From: TimMonko <47310455+TimMonko@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:48:31 -0500 Subject: [PATCH 17/17] Add functionality to capture viewer regions --- docs/_scripts/autogenerate_gui_images.py | 88 ++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py index b7857bee5..d2fe58be0 100644 --- a/docs/_scripts/autogenerate_gui_images.py +++ b/docs/_scripts/autogenerate_gui_images.py @@ -1,12 +1,11 @@ from pathlib import Path -from qtpy.QtCore import QTimer, QPoint +from qtpy.QtCore import QTimer, QPoint, QRect import napari from napari._qt.qt_event_loop import get_qapp from napari._qt.qt_resources import get_stylesheet from napari._qt.dialogs.qt_modal import QtPopup -from napari._qt.widgets.qt_viewer_buttons import QtViewerButtons from qtpy.QtWidgets import QApplication, QWidget DOCS = REPO_ROOT_PATH = Path(__file__).resolve().parent.parent @@ -18,8 +17,10 @@ MENUS_PATH.mkdir(parents=True, exist_ok=True) POPUPS_PATH = IMAGES_PATH / "popups" POPUPS_PATH.mkdir(parents=True, exist_ok=True) +REGION_PATH = IMAGES_PATH / "regions" +REGION_PATH.mkdir(parents=True, exist_ok=True) -def _get_widget_componenets(qt_window: QWidget) -> dict: +def _get_widget_components(qt_window: QWidget) -> dict: """Get visible widget components from the Qt window. Parameters @@ -102,7 +103,6 @@ def _get_button_popups_configs( viewer.window._qt_window, "QtViewerButtons" ) - return [ { "name": "ndisplay_2D_popup", @@ -126,6 +126,29 @@ def _get_button_popups_configs( } ] +def _get_viewer_regions() -> list[dict]: + """Get regions of the viewer to capture as a single image. + + Returns + ------- + list[dict] + List of dictionaries with the following keys: + - name: str + Name of the region. + - components: list of str + Names of components to determine the bounding region + """ + return [ + { + "name": "console_and_buttons", + "components": ["console_dock", "viewer_buttons"] + }, + { + "name": "layer_list_and_controls", + "components": ["layer_list_dock", "layer_controls_dock"] + }, + ] + def autogenerate_images(): """Autogenerate images of the GUI components. @@ -161,7 +184,7 @@ def autogenerate_images(): viewer.open_sample(plugin='napari', sample='cells3d') # Mouse over canvas for status bar update - viewer.layers.selection = viewer.layers + viewer.layers.selection = [viewer.layers[0]] viewer.mouse_over_canvas = True viewer.cursor.position = [25, 50, 120] viewer.update_status_from_cursor() @@ -175,9 +198,8 @@ def autogenerate_images(): app.processEvents() viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d_console.png"), canvas_only=False) - - widget_componenets = _get_widget_componenets(viewer.window._qt_window) + widget_componenets = _get_widget_components(viewer.window._qt_window) for name, widget in widget_componenets.items(): capture_widget(widget, name) @@ -189,6 +211,9 @@ def autogenerate_images(): for config in button_popups_configs: capture_popups(config) + for region in _get_viewer_regions(): + capture_viewer_region(viewer, region["components"], region["name"]) + close_all(viewer) app.exec_() @@ -238,6 +263,55 @@ def capture_menu(menu, name): menu.hide() return +def capture_viewer_region(viewer, component_names, save_name): + """Capture a screenshot of a region containing multiple components. + + Requires that the component is defined in _get_widget_components + + Parameters + ---------- + viewer : napari.Viewer + The napari viewer + component_names : list of str + Names of components to determine the bounding region + save_name : str + Name of the output image file + """ + app = get_qapp() + qt_window = viewer.window._qt_window + widget_components = _get_widget_components(qt_window) + + # Find the bounding rectangle for all requested components + min_x, min_y = float('inf'), float('inf') + max_x, max_y = float('-inf'), float('-inf') + + for name in component_names: + if name not in widget_components or widget_components[name] is None: + print(f"Component {name} not found, skipping") + continue + + widget = widget_components[name] + # Map to global coordinates + global_pos = widget.mapToGlobal(widget.rect().topLeft()) + global_rect = widget.rect() + global_rect.moveTo(global_pos) + + min_x = min(min_x, global_rect.left()) + min_y = min(min_y, global_rect.top()) + max_x = max(max_x, global_rect.right()) + max_y = max(max_y, global_rect.bottom()) + + if min_x == float('inf'): + print(f"No valid components found for {save_name}") + return + + region = QRect(QPoint(min_x, min_y), QPoint(max_x, max_y)) + + app.processEvents() + screen = QApplication.primaryScreen() + pixmap = screen.grabWindow(0, region.x(), region.y(), region.width(), region.height()) + pixmap.save(str(REGION_PATH / f"{save_name}.png")) + def close_all(viewer): viewer.close() QTimer.singleShot(10, lambda: get_qapp().quit())