diff --git a/docs/_scripts/autogenerate_gui_images.py b/docs/_scripts/autogenerate_gui_images.py new file mode 100644 index 000000000..d2fe58be0 --- /dev/null +++ b/docs/_scripts/autogenerate_gui_images.py @@ -0,0 +1,377 @@ +from pathlib import Path + +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 qtpy.QtWidgets import QApplication, QWidget + +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) +REGION_PATH = IMAGES_PATH / "regions" +REGION_PATH.mkdir(parents=True, exist_ok=True) + +def _get_widget_components(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"), + + "console_dock": find_widget_by_name(qt_window, "console"), + + "dimension_slider": find_widget_by_class(qt_window, "QtDims"), + + # Layer list components + "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(qt_window, "layer controls"), + + # TODO: mouse over part of the image to show intensity stuff + "status_bar": find_widget_by_class(qt_window, "ViewerStatusBar"), + } + +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"), + "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"), + } + +def _get_button_popups_configs( + viewer: napari.Viewer, +) -> 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", + "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, + } + ] + +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. + + 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 + 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")) + + # 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) + viewer.open_sample(plugin='napari', sample='cells3d') + + # Mouse over canvas for status bar update + viewer.layers.selection = [viewer.layers[0]] + 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() + + viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d_console.png"), canvas_only=False) + + widget_componenets = _get_widget_components(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) + 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_() + +def capture_popups(config): + """Capture popups that appear when clicking on viewer buttons.""" + app = get_qapp() + close_existing_popups() + + if config["prep"] is not None: + 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 not popups: + return print(f"No popup found for {config['name']}") + + popup = popups[-1] # grab the most recent popup, just in case + + app.processEvents() + + 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.""" + 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())) + + pixmap = menu.grab() + pixmap.save(str(MENUS_PATH / f"{name}.png")) + 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()) + +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: + 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.""" + if parent.__class__.__name__ == class_name: + return parent + + 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 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 "" + print(" " * indent + f"- {class_name}{name_str}") + + for child in widget.children(): + if hasattr(child, "children"): + print_widget_hierarchy(child, indent + 4, max_depth) + + +if __name__ == "__main__": + autogenerate_images() \ No newline at end of file