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
Show file tree
Hide file tree
Changes from 7 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
237 changes: 237 additions & 0 deletions docs/_scripts/autogenerate_gui_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
from pathlib import Path

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
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"))

# 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(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_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

# 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}")

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))

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."""

# 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."""
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

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()))

# 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 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:
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()
92 changes: 92 additions & 0 deletions docs/_scripts/autogenerate_popups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""
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)
)
))
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not a good solution. We need to find a way to define a list of popups to iterate over it.
Maybe global list and just pass the index?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

can you look strictly at autogenerate-gui? I've put the most recent popup stuff in there. It's flaky, where sometimes it works and sometimes it doesn't, I've tried to include closing popups there, but yes, trying to work towards creating a list. Sorry I didn't remove this file 😬

Copy link
Contributor

Choose a reason for hiding this comment

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

in 2-3 hours

Copy link
Contributor Author

@TimMonko TimMonko Mar 14, 2025

Choose a reason for hiding this comment

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

of course! no issue, I'm actually going to plug along on it for about another hour, so you should receive a better file by then

Copy link
Contributor Author

Choose a reason for hiding this comment

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

your review did at least confirm some of the direction I thought it needed to go


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
Copy link
Contributor

Choose a reason for hiding this comment

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

The previous popup is not closed before your code triggers creation of the next popup, so the first popup is captured again. We either need to use proper class in place of QtPopup or use objectName() method (with earlier setting it).

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()