Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autogenerate images of parts of the viewer #621

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
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
377 changes: 377 additions & 0 deletions docs/_scripts/autogenerate_gui_images.py
Original file line number Diff line number Diff line change
@@ -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()