class Window: """Application window that contains the menu bar and viewer. Parameters ---------- viewer : napari.components.ViewerModel Contained viewer widget. Attributes ---------- file_menu : qtpy.QtWidgets.QMenu File menu. help_menu : qtpy.QtWidgets.QMenu Help menu. main_menu : qtpy.QtWidgets.QMainWindow.menuBar Main menubar. qt_viewer : QtViewer Contained viewer widget. view_menu : qtpy.QtWidgets.QMenu View menu. window_menu : qtpy.QtWidgets.QMenu Window menu. """ raw_stylesheet = get_stylesheet() def __init__(self, viewer, *, show: bool = True): # Check there is a running app # instance() returns the singleton instance if it exists, or None app = QApplication.instance() # if None, raise a RuntimeError with the appropriate message if app is None: message = ( "napari requires a Qt event loop to run. To create one, " "try one of the following: \n" " - use the `napari.gui_qt()` context manager. See " "https://github.com/napari/napari/tree/master/examples for" " usage examples.\n" " - In IPython or a local Jupyter instance, use the " "`%gui qt` magic command.\n" " - Launch IPython with the option `--gui=qt`.\n" " - (recommended) in your IPython configuration file, add" " or uncomment the line `c.TerminalIPythonApp.gui = 'qt'`." " Then, restart IPython." ) raise RuntimeError(message) if perf_config: if perf_config.trace_qt_events: from .tracing.qt_event_tracing import convert_app_for_tracing # For tracing Qt events we need a special QApplication. If # using `gui_qt` we already have the special one, and no # conversion is done here. However when running inside # IPython or Jupyter this is where we switch out the # QApplication. app = convert_app_for_tracing(app) # Will patch based on config file. perf_config.patch_callables() _napari_app_id = getattr( viewer, "_napari_app_id", 'napari.napari.viewer.' + str(__version__), ) if ( platform.system() == "Windows" and not getattr(sys, 'frozen', False) and _napari_app_id ): import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( _napari_app_id ) logopath = os.path.join( os.path.dirname(__file__), '..', 'resources', 'logo.png' ) if getattr(viewer, "_napari_global_logo", True): app = QApplication.instance() app.setWindowIcon(QIcon(logopath)) # see docstring of `wait_for_workers_to_quit` for caveats on killing # workers at shutdown. app.aboutToQuit.connect(wait_for_workers_to_quit) # Connect the Viewer and create the Main Window self.qt_viewer = QtViewer(viewer) self._qt_window = QMainWindow() self._qt_window.setWindowIcon(QIcon(logopath)) self._qt_window.setAttribute(Qt.WA_DeleteOnClose) self._qt_window.setUnifiedTitleAndToolBarOnMac(True) # since we initialize canvas before window, we need to manually connect them again. if self._qt_window.windowHandle() is not None: self._qt_window.windowHandle().screenChanged.connect( self.qt_viewer.canvas._backend.screen_changed ) self._qt_center = QWidget(self._qt_window) self._qt_window.setCentralWidget(self._qt_center) self._qt_window.setWindowTitle(self.qt_viewer.viewer.title) self._qt_center.setLayout(QHBoxLayout()) self._status_bar = QStatusBar() self._qt_window.setStatusBar(self._status_bar) self._add_menubar() self._add_file_menu() self._add_view_menu() self._add_window_menu() if not os.getenv("DISABLE_ALL_PLUGINS"): self._add_plugins_menu() self._add_help_menu() self._status_bar.showMessage('Ready') self._help = QLabel('') self._status_bar.addPermanentWidget(self._help) self._qt_center.layout().addWidget(self.qt_viewer) self._qt_center.layout().setContentsMargins(4, 0, 4, 0) self._update_palette() self._add_viewer_dock_widget(self.qt_viewer.dockConsole) self._add_viewer_dock_widget(self.qt_viewer.dockLayerControls) self._add_viewer_dock_widget(self.qt_viewer.dockLayerList) self.qt_viewer.viewer.events.status.connect(self._status_changed) self.qt_viewer.viewer.events.help.connect(self._help_changed) self.qt_viewer.viewer.events.title.connect(self._title_changed) self.qt_viewer.viewer.events.palette.connect(self._update_palette) if perf.USE_PERFMON: # Add DebugMenu and dockPerformance if using perfmon. self._debug_menu = DebugMenu(self) self._add_viewer_dock_widget(self.qt_viewer.dockPerformance) else: self._debug_menu = None if show: self.show() def _add_menubar(self): """Add menubar to napari app.""" self.main_menu = self._qt_window.menuBar() # Menubar shortcuts are only active when the menubar is visible. # Therefore, we set a global shortcut not associated with the menubar # to toggle visibility, *but*, in order to not shadow the menubar # shortcut, we disable it, and only enable it when the menubar is # hidden. See this stackoverflow link for details: # https://stackoverflow.com/questions/50537642/how-to-keep-the-shortcuts-of-a-hidden-widget-in-pyqt5 self._main_menu_shortcut = QShortcut( QKeySequence('Ctrl+M'), self._qt_window ) self._main_menu_shortcut.activated.connect( self._toggle_menubar_visible ) self._main_menu_shortcut.setEnabled(False) def _toggle_menubar_visible(self): """Toggle visibility of app menubar. This function also disables or enables a global keyboard shortcut to show the menubar, since menubar shortcuts are only available while the menubar is visible. """ if self.main_menu.isVisible(): self.main_menu.setVisible(False) self._main_menu_shortcut.setEnabled(True) else: self.main_menu.setVisible(True) self._main_menu_shortcut.setEnabled(False) def _add_file_menu(self): """Add 'File' menu to app menubar.""" open_images = QAction('Open File(s)...', self._qt_window) open_images.setShortcut('Ctrl+O') open_images.setStatusTip('Open file(s)') open_images.triggered.connect(self.qt_viewer._open_files_dialog) open_stack = QAction('Open Files as Stack...', self._qt_window) open_stack.setShortcut('Ctrl+Alt+O') open_stack.setStatusTip('Open files') open_stack.triggered.connect( self.qt_viewer._open_files_dialog_as_stack_dialog ) open_folder = QAction('Open Folder...', self._qt_window) open_folder.setShortcut('Ctrl+Shift+O') open_folder.setStatusTip('Open a folder') open_folder.triggered.connect(self.qt_viewer._open_folder_dialog) save_selected_layers = QAction( 'Save Selected Layer(s)...', self._qt_window ) save_selected_layers.setShortcut('Ctrl+S') save_selected_layers.setStatusTip('Save selected layers') save_selected_layers.triggered.connect( lambda: self.qt_viewer._save_layers_dialog(selected=True) ) save_all_layers = QAction('Save All Layers...', self._qt_window) save_all_layers.setShortcut('Ctrl+Shift+S') save_all_layers.setStatusTip('Save all layers') save_all_layers.triggered.connect( lambda: self.qt_viewer._save_layers_dialog(selected=False) ) screenshot = QAction('Save Screenshot...', self._qt_window) screenshot.setShortcut('Alt+S') screenshot.setStatusTip( 'Save screenshot of current display, default .png' ) screenshot.triggered.connect(self.qt_viewer._screenshot_dialog) screenshot_wv = QAction( 'Save Screenshot with Viewer...', self._qt_window ) screenshot_wv.setShortcut('Alt+Shift+S') screenshot_wv.setStatusTip( 'Save screenshot of current display with the viewer, default .png' ) screenshot_wv.triggered.connect(self._screenshot_dialog) # OS X will rename this to Quit and put it in the app menu. exitAction = QAction('Exit', self._qt_window) exitAction.setShortcut('Ctrl+Q') exitAction.setMenuRole(QAction.QuitRole) def handle_exit(): # if the event loop was started in gui_qt() then the app will be # named 'napari'. Since the Qapp was started by us, just close it. if QApplication.applicationName() == 'napari': QApplication.closeAllWindows() QApplication.quit() # otherwise, something else created the QApp before us (such as # %gui qt IPython magic). If we quit the app in this case, then # *later* attempts to instantiate a napari viewer won't work until # the event loop is restarted with app.exec_(). So rather than # quit just close all the windows (and clear our app icon). else: QApplication.setWindowIcon(QIcon()) self.close() if perf.USE_PERFMON: # Write trace file before exit, if we were writing one. # Is there a better place to make sure this is done on exit? perf.timers.stop_trace_file() _stop_monitor() exitAction.triggered.connect(handle_exit) self.file_menu = self.main_menu.addMenu('&File') self.file_menu.addAction(open_images) self.file_menu.addAction(open_stack) self.file_menu.addAction(open_folder) self.file_menu.addSeparator() self.file_menu.addAction(save_selected_layers) self.file_menu.addAction(save_all_layers) self.file_menu.addAction(screenshot) self.file_menu.addAction(screenshot_wv) self.file_menu.addSeparator() self.file_menu.addAction(exitAction) def _add_view_menu(self): """Add 'View' menu to app menubar.""" toggle_visible = QAction('Toggle Menubar Visibility', self._qt_window) toggle_visible.setShortcut('Ctrl+M') toggle_visible.setStatusTip('Hide Menubar') toggle_visible.triggered.connect(self._toggle_menubar_visible) toggle_theme = QAction('Toggle Theme', self._qt_window) toggle_theme.setShortcut('Ctrl+Shift+T') toggle_theme.setStatusTip('Toggle theme') toggle_theme.triggered.connect(self.qt_viewer.viewer._toggle_theme) toggle_fullscreen = QAction('Toggle Full Screen', self._qt_window) toggle_fullscreen.setShortcut('Ctrl+F') toggle_fullscreen.setStatusTip('Toggle full screen') toggle_fullscreen.triggered.connect(self._toggle_fullscreen) toggle_play = QAction('Toggle Play', self._qt_window) toggle_play.triggered.connect(self._toggle_play) toggle_play.setShortcut('Ctrl+Alt+P') toggle_play.setStatusTip('Toggle Play') self.view_menu = self.main_menu.addMenu('&View') self.view_menu.addAction(toggle_fullscreen) self.view_menu.addAction(toggle_visible) self.view_menu.addAction(toggle_theme) self.view_menu.addAction(toggle_play) self.view_menu.addSeparator() # Add octree actions. if config.async_octree: toggle_outline = QAction('Toggle Chunk Outlines', self._qt_window) toggle_outline.triggered.connect( self.qt_viewer._toggle_chunk_outlines ) toggle_outline.setShortcut('Ctrl+Alt+O') toggle_outline.setStatusTip('Toggle Chunk Outlines') self.view_menu.addAction(toggle_outline) # Add axes menu axes_menu = QMenu('Axes', parent=self._qt_window) axes_visible_action = QAction( 'Visible', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.axes.visible, ) axes_visible_action.triggered.connect(self._toggle_axes_visible) axes_colored_action = QAction( 'Colored', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.axes.colored, ) axes_colored_action.triggered.connect(self._toggle_axes_colored) axes_labels_action = QAction( 'Labels', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.axes.labels, ) axes_labels_action.triggered.connect(self._toggle_axes_labels) axes_dashed_action = QAction( 'Dashed', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.axes.dashed, ) axes_dashed_action.triggered.connect(self._toggle_axes_dashed) axes_arrows_action = QAction( 'Arrows', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.axes.arrows, ) axes_arrows_action.triggered.connect(self._toggle_axes_arrows) axes_menu.addAction(axes_visible_action) axes_menu.addAction(axes_colored_action) axes_menu.addAction(axes_labels_action) axes_menu.addAction(axes_dashed_action) axes_menu.addAction(axes_arrows_action) self.view_menu.addMenu(axes_menu) # Add scale bar menu scale_bar_menu = QMenu('Scale Bar', parent=self._qt_window) scale_bar_visible_action = QAction( 'Visible', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.scale_bar.visible, ) scale_bar_visible_action.triggered.connect( self._toggle_scale_bar_visible ) scale_bar_colored_action = QAction( 'Colored', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.scale_bar.colored, ) scale_bar_colored_action.triggered.connect( self._toggle_scale_bar_colored ) scale_bar_ticks_action = QAction( 'Ticks', parent=self._qt_window, checkable=True, checked=self.qt_viewer.viewer.scale_bar.ticks, ) scale_bar_ticks_action.triggered.connect(self._toggle_scale_bar_ticks) scale_bar_menu.addAction(scale_bar_visible_action) scale_bar_menu.addAction(scale_bar_colored_action) scale_bar_menu.addAction(scale_bar_ticks_action) self.view_menu.addMenu(scale_bar_menu) self.view_menu.addSeparator() def _add_window_menu(self): """Add 'Window' menu to app menubar.""" exit_action = QAction("Close Window", self._qt_window) exit_action.setShortcut("Ctrl+W") exit_action.setStatusTip('Close napari window') exit_action.triggered.connect(self._qt_window.close) self.window_menu = self.main_menu.addMenu('&Window') self.window_menu.addAction(exit_action) def _add_plugins_menu(self): """Add 'Plugins' menu to app menubar.""" self.plugins_menu = self.main_menu.addMenu('&Plugins') pip_install_action = QAction( "Install/Uninstall Package(s)...", self._qt_window ) pip_install_action.triggered.connect(self._show_plugin_install_dialog) self.plugins_menu.addAction(pip_install_action) order_plugin_action = QAction("Plugin Call Order...", self._qt_window) order_plugin_action.setStatusTip('Change call order for plugins') order_plugin_action.triggered.connect(self._show_plugin_sorter) self.plugins_menu.addAction(order_plugin_action) report_plugin_action = QAction("Plugin Errors...", self._qt_window) report_plugin_action.setStatusTip( 'Review stack traces for plugin exceptions and notify developers' ) report_plugin_action.triggered.connect(self._show_plugin_err_reporter) self.plugins_menu.addAction(report_plugin_action) def _show_plugin_sorter(self): """Show dialog that allows users to sort the call order of plugins.""" plugin_sorter = QtPluginSorter(parent=self._qt_window) if hasattr(self, 'plugin_sorter_widget'): self.plugin_sorter_widget.show() else: self.plugin_sorter_widget = self.add_dock_widget( plugin_sorter, name='Plugin Sorter', area="right" ) def _show_plugin_install_dialog(self): """Show dialog that allows users to sort the call order of plugins.""" self.plugin_dialog = QtPluginDialog(self._qt_window) self.plugin_dialog.exec_() def _show_plugin_err_reporter(self): """Show dialog that allows users to review and report plugin errors.""" QtPluginErrReporter(parent=self._qt_window).exec_() def _add_help_menu(self): """Add 'Help' menu to app menubar.""" self.help_menu = self.main_menu.addMenu('&Help') about_action = QAction("napari Info", self._qt_window) about_action.setShortcut("Ctrl+/") about_action.setStatusTip('About napari') about_action.triggered.connect( lambda e: QtAbout.showAbout(self.qt_viewer) ) self.help_menu.addAction(about_action) about_key_bindings = QAction("Show Key Bindings", self._qt_window) about_key_bindings.setShortcut("Ctrl+Alt+/") about_key_bindings.setShortcutContext(Qt.ApplicationShortcut) about_key_bindings.setStatusTip('key_bindings') about_key_bindings.triggered.connect( self.qt_viewer.show_key_bindings_dialog ) self.help_menu.addAction(about_key_bindings) def _toggle_scale_bar_visible(self, state): self.qt_viewer.viewer.scale_bar.visible = state def _toggle_scale_bar_colored(self, state): self.qt_viewer.viewer.scale_bar.colored = state def _toggle_scale_bar_ticks(self, state): self.qt_viewer.viewer.scale_bar.ticks = state def _toggle_axes_visible(self, state): self.qt_viewer.viewer.axes.visible = state def _toggle_axes_colored(self, state): self.qt_viewer.viewer.axes.colored = state def _toggle_axes_labels(self, state): self.qt_viewer.viewer.axes.labels = state def _toggle_axes_dashed(self, state): self.qt_viewer.viewer.axes.dashed = state def _toggle_axes_arrows(self, state): self.qt_viewer.viewer.axes.arrows = state def _toggle_fullscreen(self, event): """Toggle fullscreen mode.""" if self._qt_window.isFullScreen(): self._qt_window.showNormal() else: self._qt_window.showFullScreen() def _toggle_play(self, state): """Toggle play.""" if self.qt_viewer.dims.is_playing: self.qt_viewer.dims.stop() else: axis = self.qt_viewer.viewer.dims.last_used or 0 self.qt_viewer.dims.play(axis) def add_dock_widget( self, widget: QWidget, *, name: str = '', area: str = 'bottom', allowed_areas=None, shortcut=None, ): """Convenience method to add a QDockWidget to the main window Parameters ---------- widget : QWidget `widget` will be added as QDockWidget's main widget. name : str, optional Name of dock widget to appear in window menu. area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. Returns ------- dock_widget : QtViewerDockWidget `dock_widget` that can pass viewer events. """ dock_widget = QtViewerDockWidget( self.qt_viewer, widget, name=name, area=area, allowed_areas=allowed_areas, shortcut=shortcut, ) self._add_viewer_dock_widget(dock_widget) return dock_widget def _add_viewer_dock_widget(self, dock_widget: QtViewerDockWidget): """Add a QtViewerDockWidget to the main window Parameters ---------- dock_widget : QtViewerDockWidget `dock_widget` will be added to the main window. """ dock_widget.setParent(self._qt_window) self._qt_window.addDockWidget(dock_widget.qt_area, dock_widget) action = dock_widget.toggleViewAction() action.setStatusTip(dock_widget.name) action.setText(dock_widget.name) if dock_widget.shortcut is not None: action.setShortcut(dock_widget.shortcut) self.window_menu.addAction(action) def remove_dock_widget(self, widget): """Removes specified dock widget. Parameters ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. """ if widget == 'all': for dw in self._qt_window.findChildren(QDockWidget): self._qt_window.removeDockWidget(dw) else: self._qt_window.removeDockWidget(widget) def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def show(self): """Resize, show, and bring forward the window.""" self._qt_window.resize(self._qt_window.layout().sizeHint()) self._qt_window.show() # Resize axis labels now that window is shown self.qt_viewer.dims._resize_axis_labels() # We want to bring the viewer to the front when # A) it is our own (gui_qt) event loop OR we are running in jupyter # B) it is not the first time a QMainWindow is being created # `app_name` will be "napari" iff the application was instantiated in # gui_qt(). isActiveWindow() will be True if it is the second time a # _qt_window has been created. # See #721, #732, #735, #795, #1594 app_name = QApplication.instance().applicationName() if ( app_name == 'napari' or in_jupyter() ) and self._qt_window.isActiveWindow(): self.activate() def activate(self): """Make the viewer the currently active window.""" self._qt_window.raise_() # for macOS self._qt_window.activateWindow() # for Windows def _update_palette(self, event=None): """Update widget color palette.""" # set window styles which don't use the primary stylesheet # FIXME: this is a problem with the stylesheet not using properties palette = self.qt_viewer.viewer.palette self._status_bar.setStyleSheet( template( 'QStatusBar { background: {{ background }}; ' 'color: {{ text }}; }', **palette, ) ) self._qt_center.setStyleSheet( template('QWidget { background: {{ background }}; }', **palette) ) self._qt_window.setStyleSheet(template(self.raw_stylesheet, **palette)) def _status_changed(self, event): """Update status bar. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._status_bar.showMessage(event.text) def _title_changed(self, event): """Update window title. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._qt_window.setWindowTitle(event.text) def _help_changed(self, event): """Update help message on status bar. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._help.setText(event.text) def _screenshot_dialog(self): """Save screenshot of current display with viewer, default .png""" dial = ScreenshotDialog( self.screenshot, self.qt_viewer, self.qt_viewer._last_visited_dir ) if dial.exec_(): self._last_visited_dir = os.path.dirname(dial.selectedFiles()[0]) def screenshot(self, path=None): """Take currently displayed viewer and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = self._qt_window.grab().toImage() if path is not None: imsave(path, QImg2array(img)) # scikit-image imsave method return QImg2array(img) def close(self): """Close the viewer window and cleanup sub-widgets.""" # Someone is closing us twice? Only try to delete self._qt_window # if we still have one. if hasattr(self, '_qt_window'): self._delete_qt_window() def _delete_qt_window(self): """Delete our self._qt_window.""" # On some versions of Darwin, exiting while fullscreen seems to tickle # some bug deep in NSWindow. This forces the fullscreen keybinding # test to complete its draw cycle, then pop back out of fullscreen. if self._qt_window.isFullScreen(): self._qt_window.showNormal() for i in range(8): time.sleep(0.1) QApplication.processEvents() self.qt_viewer.close() self._qt_window.close() del self._qt_window
class Window: """Application window that contains the menu bar and viewer. Parameters ---------- qt_viewer : QtViewer Contained viewer widget. Attributes ---------- file_menu : qtpy.QtWidgets.QMenu File menu. help_menu : qtpy.QtWidgets.QMenu Help menu. main_menu : qtpy.QtWidgets.QMainWindow.menuBar Main menubar. qt_viewer : QtViewer Contained viewer widget. view_menu : qtpy.QtWidgets.QMenu View menu. window_menu : qtpy.QtWidgets.QMenu Window menu. """ raw_stylesheet = combine_stylesheets() def __init__(self, qt_viewer, *, show=True): self.qt_viewer = qt_viewer self._qt_window = QMainWindow() self._qt_window.setAttribute(Qt.WA_DeleteOnClose) self._qt_window.setUnifiedTitleAndToolBarOnMac(True) self._qt_center = QWidget(self._qt_window) self._qt_window.setCentralWidget(self._qt_center) self._qt_window.setWindowTitle(self.qt_viewer.viewer.title) self._qt_center.setLayout(QHBoxLayout()) self._status_bar = QStatusBar() self._qt_window.setStatusBar(self._status_bar) self._add_menubar() self._add_file_menu() self._add_view_menu() self._add_window_menu() self._add_help_menu() self._status_bar.showMessage('Ready') self._help = QLabel('') self._status_bar.addPermanentWidget(self._help) self._qt_center.layout().addWidget(self.qt_viewer) self._qt_center.layout().setContentsMargins(4, 0, 4, 0) self._update_palette(qt_viewer.viewer.palette) if self.qt_viewer.console.shell is not None: self._add_viewer_dock_widget(self.qt_viewer.dockConsole) self._add_viewer_dock_widget(self.qt_viewer.dockLayerControls) self._add_viewer_dock_widget(self.qt_viewer.dockLayerList) self.qt_viewer.viewer.events.status.connect(self._status_changed) self.qt_viewer.viewer.events.help.connect(self._help_changed) self.qt_viewer.viewer.events.title.connect(self._title_changed) self.qt_viewer.viewer.events.palette.connect( lambda event: self._update_palette(event.palette)) if show: self.show() def _add_menubar(self): """Add menubar to napari app.""" self.main_menu = self._qt_window.menuBar() # Menubar shortcuts are only active when the menubar is visible. # Therefore, we set a global shortcut not associated with the menubar # to toggle visibility, *but*, in order to not shadow the menubar # shortcut, we disable it, and only enable it when the menubar is # hidden. See this stackoverflow link for details: # https://stackoverflow.com/questions/50537642/how-to-keep-the-shortcuts-of-a-hidden-widget-in-pyqt5 self._main_menu_shortcut = QShortcut(QKeySequence('Ctrl+M'), self._qt_window) self._main_menu_shortcut.activated.connect( self._toggle_menubar_visible) self._main_menu_shortcut.setEnabled(False) def _toggle_menubar_visible(self): """Toggle visibility of app menubar. This function also disables or enables a global keyboard shortcut to show the menubar, since menubar shortcuts are only available while the menubar is visible. """ if self.main_menu.isVisible(): self.main_menu.setVisible(False) self._main_menu_shortcut.setEnabled(True) else: self.main_menu.setVisible(True) self._main_menu_shortcut.setEnabled(False) def _add_file_menu(self): """Add 'File' menu to app menubar.""" open_images = QAction('Open image(s)...', self._qt_window) open_images.setShortcut('Ctrl+O') open_images.setStatusTip('Open image file(s)') open_images.triggered.connect(self.qt_viewer._open_images) open_folder = QAction('Open Folder...', self._qt_window) open_folder.setShortcut('Ctrl+Shift+O') open_folder.setStatusTip( 'Open a folder of image file(s) or a zarr file') open_folder.triggered.connect(self.qt_viewer._open_folder) screenshot = QAction('Screenshot', self._qt_window) screenshot.setShortcut('Ctrl+Alt+S') screenshot.setStatusTip( 'Save screenshot of current display, default .png') screenshot.triggered.connect(self.qt_viewer._save_screenshot) self.file_menu = self.main_menu.addMenu('&File') self.file_menu.addAction(open_images) self.file_menu.addAction(open_folder) self.file_menu.addAction(screenshot) def _add_view_menu(self): """Add 'View' menu to app menubar.""" toggle_visible = QAction('Toggle menubar visibility', self._qt_window) toggle_visible.setShortcut('Ctrl+M') toggle_visible.setStatusTip('Hide Menubar') toggle_visible.triggered.connect(self._toggle_menubar_visible) toggle_theme = QAction('Toggle theme', self._qt_window) toggle_theme.setShortcut('Ctrl+Shift+T') toggle_theme.setStatusTip('Toggle theme') toggle_theme.triggered.connect(self.qt_viewer.viewer._toggle_theme) self.view_menu = self.main_menu.addMenu('&View') self.view_menu.addAction(toggle_visible) self.view_menu.addAction(toggle_theme) def _add_window_menu(self): """Add 'Window' menu to app menubar.""" exit_action = QAction("Close window", self._qt_window) exit_action.setShortcut("Ctrl+W") exit_action.setStatusTip('Close napari window') exit_action.triggered.connect(self._qt_window.close) self.window_menu = self.main_menu.addMenu('&Window') self.window_menu.addAction(exit_action) def _add_help_menu(self): """Add 'Help' menu to app menubar.""" self.help_menu = self.main_menu.addMenu('&Help') about_action = QAction("napari info", self._qt_window) about_action.setShortcut("Ctrl+/") about_action.setStatusTip('About napari') about_action.triggered.connect( lambda e: QtAbout.showAbout(self.qt_viewer)) self.help_menu.addAction(about_action) about_keybindings = QAction("keybindings", self._qt_window) about_keybindings.setShortcut("Ctrl+Alt+/") about_keybindings.setShortcutContext(Qt.ApplicationShortcut) about_keybindings.setStatusTip('keybindings') about_keybindings.triggered.connect( self.qt_viewer.show_keybindings_dialog) self.help_menu.addAction(about_keybindings) def add_dock_widget( self, widget: QWidget, *, name: str = '', area: str = 'bottom', allowed_areas=None, shortcut=None, ): """Convenience method to add a QDockWidget to the main window Parameters ---------- widget : QWidget `widget` will be added as QDockWidget's main widget. name : str, optional Name of dock widget to appear in window menu. area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. Returns ------- dock_widget : QtViewerDockWidget `dock_widget` that can pass viewer events. """ dock_widget = QtViewerDockWidget( self.qt_viewer, widget, name=name, area=area, allowed_areas=allowed_areas, shortcut=shortcut, ) self._add_viewer_dock_widget(dock_widget) return dock_widget def _add_viewer_dock_widget(self, dock_widget: QtViewerDockWidget): """Add a QtViewerDockWidget to the main window Parameters ---------- dock_widget : QtViewerDockWidget `dock_widget` will be added to the main window. """ dock_widget.setParent(self._qt_window) self._qt_window.addDockWidget(dock_widget.qt_area, dock_widget) action = dock_widget.toggleViewAction() action.setStatusTip(dock_widget.name) action.setText(dock_widget.name) if dock_widget.shortcut is not None: action.setShortcut(dock_widget.shortcut) self.window_menu.addAction(action) def remove_dock_widget(self, widget): """Removes specified dock widget. Parameters ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. """ if widget == 'all': for dw in self._qt_window.findChildren(QDockWidget): self._qt_window.removeDockWidget(dw) else: self._qt_window.removeDockWidget(widget) def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def show(self): """Resize, show, and bring forward the window.""" self._qt_window.resize(self._qt_window.layout().sizeHint()) self._qt_window.show() # We want to call Window._qt_window.raise_() in every case *except* # when instantiating a viewer within a gui_qt() context for the # _first_ time within the Qt app's lifecycle. # # `app_name` will be "napari" iff the application was instantiated in # gui_qt(). isActiveWindow() will be True if it is the second time a # _qt_window has been created. See #732 app_name = QApplication.instance().applicationName() if app_name != 'napari' or self._qt_window.isActiveWindow(): self._qt_window.raise_() # for macOS self._qt_window.activateWindow() # for Windows def _update_palette(self, palette): """Update widget color palette. Parameters ---------- palette : qtpy.QtGui.QPalette Color palette for each widget state (Active, Disabled, Inactive). """ # set window styles which don't use the primary stylesheet # FIXME: this is a problem with the stylesheet not using properties self._status_bar.setStyleSheet( template( 'QStatusBar { background: {{ background }}; ' 'color: {{ text }}; }', **palette, )) self._qt_center.setStyleSheet( template('QWidget { background: {{ background }}; }', **palette)) self._qt_window.setStyleSheet(template(self.raw_stylesheet, **palette)) def _status_changed(self, event): """Update status bar. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._status_bar.showMessage(event.text) def _title_changed(self, event): """Update window title. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._qt_window.setWindowTitle(event.text) def _help_changed(self, event): """Update help message on status bar. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._help.setText(event.text) def screenshot(self, path=None): """Take currently displayed viewer and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = self._qt_window.grab().toImage() if path is not None: imsave(path, QImg2array(img)) # scikit-image imsave method return QImg2array(img) def close(self): """Close the viewer window and cleanup sub-widgets.""" # on some versions of Darwin, exiting while fullscreen seems to tickle # some bug deep in NSWindow. This forces the fullscreen keybinding # test to complete its draw cycle, then pop back out of fullscreen. if self._qt_window.isFullScreen(): self._qt_window.showNormal() for i in range(8): time.sleep(0.1) QApplication.processEvents() self.qt_viewer.close() self._qt_window.close() del self._qt_window
class Window: """Application window that contains the menu bar and viewer. Parameters ---------- qt_widget : QtViewer Contained viewer widget. Attributes ---------- file_menu : qtpy.QtWidgets.QMenu File menu. help_menu : qtpy.QtWidgets.QMenu Help menu. main_menu : qtpy.QtWidgets.QMainWindow.menuBar Main menubar. qt_widget : QtViewer Contained viewer widget. view_menu : qtpy.QtWidgets.QMenu View menu. window_menu : qtpy.QtWidgets.QMenu Window menu. """ def __init__(self, qt_widget, *, show): self.qt_widget = qt_widget self._qt_window = QMainWindow() self._qt_window.setAttribute(Qt.WA_DeleteOnClose) self._qt_window.setUnifiedTitleAndToolBarOnMac(True) self._qt_center = QWidget(self._qt_window) self._qt_window.setCentralWidget(self._qt_center) if hasattr(self.qt_widget.model, "title"): self._qt_window.setWindowTitle(self.qt_widget.model.title) self._qt_center.setLayout(QHBoxLayout()) self._status_bar = QStatusBar() self._qt_window.setStatusBar(self._status_bar) self._status_bar.showMessage("Ready") self._help = QLabel("") self._status_bar.addPermanentWidget(self._help) self._qt_center.layout().addWidget(self.qt_widget) self._qt_center.layout().setContentsMargins(4, 0, 4, 0) # self._add_viewer_dock_widget(self.qt_widget.dockConsole) # self._add_viewer_dock_widget(self.qt_widget.dockLayerControls) # self._add_viewer_dock_widget(self.qt_widget.dockLayerList) # self.qt_widget.viewer.events.status.connect(self._status_changed) # self.qt_widget.viewer.events.help.connect(self._help_changed) # self.qt_widget.viewer.events.title.connect(self._title_changed) # self.qt_widget.viewer.events.palette.connect(self._update_palette) if show: self.show() def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def show(self): """Resize, show, and bring forward the window.""" self._qt_window.resize(self._qt_window.layout().sizeHint()) self._qt_window.show() # We want to call Window._qt_window.raise_() in every case *except* # when instantiating a viewer within a gui_qt() context for the # _first_ time within the Qt app's lifecycle. # # `app_name` will be ours iff the application was instantiated in # gui_qt(). isActiveWindow() will be True if it is the second time a # _qt_window has been created. See #732 app_name = QApplication.instance().applicationName() if app_name != get_our_app_name() or self._qt_window.isActiveWindow(): self._qt_window.raise_() # for macOS self._qt_window.activateWindow() # for Windows def _status_changed(self, event): """Update status bar. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._status_bar.showMessage(event.text) def _title_changed(self, event): """Update window title. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._qt_window.setWindowTitle(event.text) def _help_changed(self, event): """Update help message on status bar. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._help.setText(event.text) def _screenshot_dialog(self): """Save screenshot of current display with viewer, default .png""" filename, _ = QFileDialog.getSaveFileName( parent=self.qt_widget, caption="Save screenshot with viewer", directory=self.qt_widget._last_visited_dir, # home dir by default filter="Image files (*.png *.bmp *.gif *.tif *.tiff)", # first one used by default # jpg and jpeg not included as they don't support an alpha channel ) if (filename != "") and (filename is not None): # double check that an appropriate extension has been added as the # filter option does not always add an extension on linux and windows # see https://bugreports.qt.io/browse/QTBUG-27186 image_extensions = (".bmp", ".gif", ".png", ".tif", ".tiff") if not filename.endswith(image_extensions): filename = filename + ".png" self.screenshot(path=filename) def screenshot(self, path=None): """Take currently displayed viewer and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = self._qt_window.grab().toImage() if path is not None: from skimage.io import imsave from .utils import QImg2array # noqa: E402 imsave(path, QImg2array(img)) return QImg2array(img) def close(self): """Close the viewer window and cleanup sub-widgets.""" # on some versions of Darwin, exiting while fullscreen seems to tickle # some bug deep in NSWindow. This forces the fullscreen keybinding # test to complete its draw cycle, then pop back out of fullscreen. if self._qt_window.isFullScreen(): self._qt_window.showNormal() for i in range(8): time.sleep(0.1) QApplication.processEvents() self.qt_widget.close() self._qt_window.close() wait_for_workers_to_quit() del self._qt_window
class PythonFileInterpreter(QWidget): sig_editor_modified = Signal(bool) sig_filename_modified = Signal(str) sig_progress = Signal(int) sig_exec_error = Signal(object) sig_exec_success = Signal(object) def __init__(self, font=None, content=None, filename=None, parent=None): """ :param font: A reference to the font to be used by the editor. If not supplied use the system default :param content: An optional string of content to pass to the editor :param filename: The file path where the content was read. :param parent: An optional parent QWidget """ super(PythonFileInterpreter, self).__init__(parent) self.parent = parent # layout font = font if font is not None else QFont() self.editor = CodeEditor("AlternateCSPython", font, self) self.find_replace_dialog = None self.status = QStatusBar(self) self.layout = QVBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) self.layout.addWidget(self.editor) self.layout.addWidget(self.status) self.setLayout(self.layout) self._setup_editor(content, filename) self._presenter = PythonFileInterpreterPresenter(self, PythonCodeExecution(self.editor)) self.code_commenter = CodeCommenter(self.editor) self.code_completer = CodeCompleter(self.editor, self._presenter.model.globals_ns) self.editor.modificationChanged.connect(self.sig_editor_modified) self.editor.fileNameChanged.connect(self.sig_filename_modified) self.setAttribute(Qt.WA_DeleteOnClose, True) # Connect the model signals to the view's signals so they can be accessed from outside the MVP self._presenter.model.sig_exec_error.connect(self.sig_exec_error) self._presenter.model.sig_exec_success.connect(self.sig_exec_success) # Re-populate the completion API after execution success self._presenter.model.sig_exec_success.connect(self.code_completer.update_completion_api) def closeEvent(self, event): self.deleteLater() if self.find_replace_dialog: self.find_replace_dialog.close() super(PythonFileInterpreter, self).closeEvent(event) def show_find_replace_dialog(self): if self.find_replace_dialog is None: self.find_replace_dialog = EmbeddedFindReplaceDialog(self, self.editor) self.layout.insertWidget(0, self.find_replace_dialog.view) self.find_replace_dialog.show() def hide_find_replace_dialog(self): if self.find_replace_dialog is not None: self.find_replace_dialog.hide() @property def filename(self): return self.editor.fileName() def confirm_close(self): """Confirm the widget can be closed. If the editor contents are modified then a user can interject and cancel closing. :return: True if closing was considered successful, false otherwise """ return self.save(prompt_for_confirmation=self.parent.confirm_on_save) def abort(self): self._presenter.req_abort() def execute_async(self, ignore_selection=False): return self._presenter.req_execute_async(ignore_selection) def execute_async_blocking(self): self._presenter.req_execute_async_blocking() def save(self, prompt_for_confirmation=False, force_save=False): if self.editor.isModified(): io = EditorIO(self.editor) return io.save_if_required(prompt_for_confirmation, force_save) else: return True def save_as(self): io = EditorIO(self.editor) new_filename = io.ask_for_filename() if new_filename: return io.write(save_as=new_filename), new_filename else: return False, None def set_editor_readonly(self, ro): self.editor.setReadOnly(ro) def set_status_message(self, msg): self.status.showMessage(msg) def replace_tabs_with_spaces(self): self.replace_text(TAB_CHAR, SPACE_CHAR * TAB_WIDTH) def replace_text(self, match_text, replace_text): if self.editor.selectedText() == '': self.editor.selectAll() new_text = self.editor.selectedText().replace(match_text, replace_text) self.editor.replaceSelectedText(new_text) def replace_spaces_with_tabs(self): self.replace_text(SPACE_CHAR * TAB_WIDTH, TAB_CHAR) def set_whitespace_visible(self): self.editor.setEolVisibility(True) self.editor.setWhitespaceVisibility(CodeEditor.WsVisible) def set_whitespace_invisible(self): self.editor.setEolVisibility(False) self.editor.setWhitespaceVisibility(CodeEditor.WsInvisible) def toggle_comment(self): self.code_commenter.toggle_comment() def _setup_editor(self, default_content, filename): editor = self.editor # Clear default QsciScintilla key bindings that we want to allow # to be users of this class self.clear_key_binding("Ctrl+/") # use tabs not spaces for indentation editor.setIndentationsUseTabs(False) editor.setTabWidth(TAB_WIDTH) # show current editing line but in a softer color editor.setCaretLineBackgroundColor(CURRENTLINE_BKGD_COLOR) editor.setCaretLineVisible(True) # set a margin large enough for sensible file sizes < 1000 lines # and the progress marker font_metrics = QFontMetrics(self.font()) editor.setMarginWidth(1, font_metrics.averageCharWidth() * 3 + 20) # fill with content if supplied and set source filename if default_content is not None: editor.setText(default_content) if filename is not None: editor.setFileName(filename) # Default content does not count as a modification editor.setModified(False) def clear_key_binding(self, key_str): """Clear a keyboard shortcut bound to a Scintilla command""" self.editor.clearKeyBinding(key_str)
class Window: """Application window that contains the menu bar and viewer. Parameters ---------- qt_viewer : QtViewer Contained viewer widget. Attributes ---------- file_menu : qtpy.QtWidgets.QMenu File menu. help_menu : qtpy.QtWidgets.QMenu Help menu. main_menu : qtpy.QtWidgets.QMainWindow.menuBar Main menubar. qt_viewer : QtViewer Contained viewer widget. view_menu : qtpy.QtWidgets.QMenu View menu. window_menu : qtpy.QtWidgets.QMenu Window menu. """ raw_stylesheet = get_stylesheet() def __init__(self, qt_viewer: QtViewer, *, show: bool = True): self.qt_viewer = qt_viewer self._qt_window = QMainWindow() self._qt_window.setAttribute(Qt.WA_DeleteOnClose) self._qt_window.setUnifiedTitleAndToolBarOnMac(True) self._qt_center = QWidget(self._qt_window) self._qt_window.setCentralWidget(self._qt_center) self._qt_window.setWindowTitle(self.qt_viewer.viewer.title) self._qt_center.setLayout(QHBoxLayout()) self._status_bar = QStatusBar() self._qt_window.setStatusBar(self._status_bar) self._add_menubar() self._add_file_menu() self._add_view_menu() self._add_window_menu() self._add_plugins_menu() self._add_help_menu() self._status_bar.showMessage('Ready') self._help = QLabel('') self._status_bar.addPermanentWidget(self._help) self._qt_center.layout().addWidget(self.qt_viewer) self._qt_center.layout().setContentsMargins(4, 0, 4, 0) self._update_palette() self._add_viewer_dock_widget(self.qt_viewer.dockConsole) self._add_viewer_dock_widget(self.qt_viewer.dockLayerControls) self._add_viewer_dock_widget(self.qt_viewer.dockLayerList) self.qt_viewer.viewer.events.status.connect(self._status_changed) self.qt_viewer.viewer.events.help.connect(self._help_changed) self.qt_viewer.viewer.events.title.connect(self._title_changed) self.qt_viewer.viewer.events.palette.connect(self._update_palette) if perf.USE_PERFMON: # Add DebugMenu if using perfmon. The DebugMenu is intended to # contain non-perfmon stuff as well. When it does we will want # a separate env variable for it. self._debug_menu = DebugMenu(self) # The QtPerformance widget only exists if we are using perfmon. self._add_viewer_dock_widget(self.qt_viewer.dockPerformance) else: self._debug_menu = None if show: self.show() def _add_menubar(self): """Add menubar to napari app.""" self.main_menu = self._qt_window.menuBar() # Menubar shortcuts are only active when the menubar is visible. # Therefore, we set a global shortcut not associated with the menubar # to toggle visibility, *but*, in order to not shadow the menubar # shortcut, we disable it, and only enable it when the menubar is # hidden. See this stackoverflow link for details: # https://stackoverflow.com/questions/50537642/how-to-keep-the-shortcuts-of-a-hidden-widget-in-pyqt5 self._main_menu_shortcut = QShortcut(QKeySequence('Ctrl+M'), self._qt_window) self._main_menu_shortcut.activated.connect( self._toggle_menubar_visible) self._main_menu_shortcut.setEnabled(False) def _toggle_menubar_visible(self): """Toggle visibility of app menubar. This function also disables or enables a global keyboard shortcut to show the menubar, since menubar shortcuts are only available while the menubar is visible. """ if self.main_menu.isVisible(): self.main_menu.setVisible(False) self._main_menu_shortcut.setEnabled(True) else: self.main_menu.setVisible(True) self._main_menu_shortcut.setEnabled(False) def _add_file_menu(self): """Add 'File' menu to app menubar.""" open_images = QAction('Open File(s)...', self._qt_window) open_images.setShortcut('Ctrl+O') open_images.setStatusTip('Open file(s)') open_images.triggered.connect(self.qt_viewer._open_files_dialog) open_stack = QAction('Open Files as Stack...', self._qt_window) open_stack.setShortcut('Ctrl+Alt+O') open_stack.setStatusTip('Open files') open_stack.triggered.connect( self.qt_viewer._open_files_dialog_as_stack_dialog) open_folder = QAction('Open Folder...', self._qt_window) open_folder.setShortcut('Ctrl+Shift+O') open_folder.setStatusTip('Open a folder') open_folder.triggered.connect(self.qt_viewer._open_folder_dialog) save_selected_layers = QAction('Save Selected Layer(s)...', self._qt_window) save_selected_layers.setShortcut('Ctrl+S') save_selected_layers.setStatusTip('Save selected layers') save_selected_layers.triggered.connect( lambda: self.qt_viewer._save_layers_dialog(selected=True)) save_all_layers = QAction('Save All Layers...', self._qt_window) save_all_layers.setShortcut('Ctrl+Shift+S') save_all_layers.setStatusTip('Save all layers') save_all_layers.triggered.connect( lambda: self.qt_viewer._save_layers_dialog(selected=False)) screenshot = QAction('Save Screenshot...', self._qt_window) screenshot.setShortcut('Alt+S') screenshot.setStatusTip( 'Save screenshot of current display, default .png') screenshot.triggered.connect(self.qt_viewer._screenshot_dialog) screenshot_wv = QAction('Save Screenshot with Viewer...', self._qt_window) screenshot_wv.setShortcut('Alt+Shift+S') screenshot_wv.setStatusTip( 'Save screenshot of current display with the viewer, default .png') screenshot_wv.triggered.connect(self._screenshot_dialog) # OS X will rename this to Quit and put it in the app menu. exitAction = QAction('Exit', self._qt_window) exitAction.setShortcut('Ctrl+Q') exitAction.setMenuRole(QAction.QuitRole) def handle_exit(): # if the event loop was started in gui_qt() then the app will be # named 'napari'. Since the Qapp was started by us, just close it. if QApplication.applicationName() == 'napari': QApplication.closeAllWindows() QApplication.quit() # otherwise, something else created the QApp before us (such as # %gui qt IPython magic). If we quit the app in this case, then # *later* attempts to instantiate a napari viewer won't work until # the event loop is restarted with app.exec_(). So rather than # quit just close all the windows (and clear our app icon). else: QApplication.setWindowIcon(QIcon()) self.close() if perf.USE_PERFMON: # Write trace file before exit, if we were writing one. # Is there a better place to make sure this is done on exit? perf.timers.stop_trace_file() exitAction.triggered.connect(handle_exit) self.file_menu = self.main_menu.addMenu('&File') self.file_menu.addAction(open_images) self.file_menu.addAction(open_stack) self.file_menu.addAction(open_folder) self.file_menu.addSeparator() self.file_menu.addAction(save_selected_layers) self.file_menu.addAction(save_all_layers) self.file_menu.addAction(screenshot) self.file_menu.addAction(screenshot_wv) self.file_menu.addSeparator() self.file_menu.addAction(exitAction) def _add_view_menu(self): """Add 'View' menu to app menubar.""" toggle_visible = QAction('Toggle Menubar Visibility', self._qt_window) toggle_visible.setShortcut('Ctrl+M') toggle_visible.setStatusTip('Hide Menubar') toggle_visible.triggered.connect(self._toggle_menubar_visible) toggle_theme = QAction('Toggle Theme', self._qt_window) toggle_theme.setShortcut('Ctrl+Shift+T') toggle_theme.setStatusTip('Toggle theme') toggle_theme.triggered.connect(self.qt_viewer.viewer._toggle_theme) self.view_menu = self.main_menu.addMenu('&View') self.view_menu.addAction(toggle_visible) self.view_menu.addAction(toggle_theme) def _add_window_menu(self): """Add 'Window' menu to app menubar.""" exit_action = QAction("Close Window", self._qt_window) exit_action.setShortcut("Ctrl+W") exit_action.setStatusTip('Close napari window') exit_action.triggered.connect(self._qt_window.close) self.window_menu = self.main_menu.addMenu('&Window') self.window_menu.addAction(exit_action) def _add_plugins_menu(self): """Add 'Plugins' menu to app menubar.""" self.plugins_menu = self.main_menu.addMenu('&Plugins') list_plugins_action = QAction("List Installed Plugins...", self._qt_window) list_plugins_action.setStatusTip('List installed plugins') list_plugins_action.triggered.connect(self._show_plugin_list) self.plugins_menu.addAction(list_plugins_action) order_plugin_action = QAction("Plugin Call Order...", self._qt_window) order_plugin_action.setStatusTip('Change call order for plugins') order_plugin_action.triggered.connect(self._show_plugin_sorter) self.plugins_menu.addAction(order_plugin_action) report_plugin_action = QAction("Plugin Errors...", self._qt_window) report_plugin_action.setStatusTip( 'Review stack traces for plugin exceptions and notify developers') report_plugin_action.triggered.connect(self._show_plugin_err_reporter) self.plugins_menu.addAction(report_plugin_action) def _show_plugin_list(self, plugin_manager=None): """Show dialog with a table of installed plugins and metadata.""" QtPluginTable(self._qt_window).exec_() def _show_plugin_sorter(self): """Show dialog that allows users to sort the call order of plugins.""" plugin_sorter = QtPluginSorter(parent=self._qt_window) dock_widget = self.add_dock_widget(plugin_sorter, name='Plugin Sorter', area="right") plugin_sorter.finished.connect(dock_widget.close) plugin_sorter.finished.connect(plugin_sorter.deleteLater) plugin_sorter.finished.connect(dock_widget.deleteLater) def _show_plugin_err_reporter(self): """Show dialog that allows users to review and report plugin errors.""" plugin_sorter = QtPluginErrReporter(parent=self._qt_window) plugin_sorter.exec_() def _add_help_menu(self): """Add 'Help' menu to app menubar.""" self.help_menu = self.main_menu.addMenu('&Help') about_action = QAction("napari Info", self._qt_window) about_action.setShortcut("Ctrl+/") about_action.setStatusTip('About napari') about_action.triggered.connect( lambda e: QtAbout.showAbout(self.qt_viewer)) self.help_menu.addAction(about_action) about_key_bindings = QAction("Show Key Bindings", self._qt_window) about_key_bindings.setShortcut("Ctrl+Alt+/") about_key_bindings.setShortcutContext(Qt.ApplicationShortcut) about_key_bindings.setStatusTip('key_bindings') about_key_bindings.triggered.connect( self.qt_viewer.show_key_bindings_dialog) self.help_menu.addAction(about_key_bindings) def add_dock_widget( self, widget: QWidget, *, name: str = '', area: str = 'bottom', allowed_areas=None, shortcut=None, ): """Convenience method to add a QDockWidget to the main window Parameters ---------- widget : QWidget `widget` will be added as QDockWidget's main widget. name : str, optional Name of dock widget to appear in window menu. area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. Returns ------- dock_widget : QtViewerDockWidget `dock_widget` that can pass viewer events. """ dock_widget = QtViewerDockWidget( self.qt_viewer, widget, name=name, area=area, allowed_areas=allowed_areas, shortcut=shortcut, ) self._add_viewer_dock_widget(dock_widget) return dock_widget def _add_viewer_dock_widget(self, dock_widget: QtViewerDockWidget): """Add a QtViewerDockWidget to the main window Parameters ---------- dock_widget : QtViewerDockWidget `dock_widget` will be added to the main window. """ dock_widget.setParent(self._qt_window) self._qt_window.addDockWidget(dock_widget.qt_area, dock_widget) action = dock_widget.toggleViewAction() action.setStatusTip(dock_widget.name) action.setText(dock_widget.name) if dock_widget.shortcut is not None: action.setShortcut(dock_widget.shortcut) self.window_menu.addAction(action) def remove_dock_widget(self, widget): """Removes specified dock widget. Parameters ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. """ if widget == 'all': for dw in self._qt_window.findChildren(QDockWidget): self._qt_window.removeDockWidget(dw) else: self._qt_window.removeDockWidget(widget) def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def show(self): """Resize, show, and bring forward the window.""" self._qt_window.resize(self._qt_window.layout().sizeHint()) self._qt_window.show() # Resize axis labels now that window is shown self.qt_viewer.dims._resize_axis_labels() # We want to call Window._qt_window.raise_() in every case *except* # when instantiating a viewer within a gui_qt() context for the # _first_ time within the Qt app's lifecycle. # # `app_name` will be "napari" iff the application was instantiated in # gui_qt(). isActiveWindow() will be True if it is the second time a # _qt_window has been created. See #732 app_name = QApplication.instance().applicationName() if app_name != 'napari' or self._qt_window.isActiveWindow(): self._qt_window.raise_() # for macOS self._qt_window.activateWindow() # for Windows def _update_palette(self, event=None): """Update widget color palette.""" # set window styles which don't use the primary stylesheet # FIXME: this is a problem with the stylesheet not using properties palette = self.qt_viewer.viewer.palette self._status_bar.setStyleSheet( template( 'QStatusBar { background: {{ background }}; ' 'color: {{ text }}; }', **palette, )) self._qt_center.setStyleSheet( template('QWidget { background: {{ background }}; }', **palette)) self._qt_window.setStyleSheet(template(self.raw_stylesheet, **palette)) def _status_changed(self, event): """Update status bar. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._status_bar.showMessage(event.text) def _title_changed(self, event): """Update window title. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._qt_window.setWindowTitle(event.text) def _help_changed(self, event): """Update help message on status bar. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._help.setText(event.text) def _screenshot_dialog(self): """Save screenshot of current display with viewer, default .png""" filename, _ = QFileDialog.getSaveFileName( parent=self.qt_viewer, caption='Save screenshot with viewer', directory=self.qt_viewer._last_visited_dir, # home dir by default filter= "Image files (*.png *.bmp *.gif *.tif *.tiff)", # first one used by default # jpg and jpeg not included as they don't support an alpha channel ) if (filename != '') and (filename is not None): # double check that an appropriate extension has been added as the # filter option does not always add an extension on linux and windows # see https://bugreports.qt.io/browse/QTBUG-27186 image_extensions = ('.bmp', '.gif', '.png', '.tif', '.tiff') if not filename.endswith(image_extensions): filename = filename + '.png' self.screenshot(path=filename) def screenshot(self, path=None): """Take currently displayed viewer and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = self._qt_window.grab().toImage() if path is not None: imsave(path, QImg2array(img)) # scikit-image imsave method return QImg2array(img) def close(self): """Close the viewer window and cleanup sub-widgets.""" # on some versions of Darwin, exiting while fullscreen seems to tickle # some bug deep in NSWindow. This forces the fullscreen keybinding # test to complete its draw cycle, then pop back out of fullscreen. if self._qt_window.isFullScreen(): self._qt_window.showNormal() for i in range(8): time.sleep(0.1) QApplication.processEvents() self.qt_viewer.close() self._qt_window.close() del self._qt_window
class Window: """Application window that contains the menu bar and viewer. Parameters ---------- qt_viewer : QtViewer Contained viewer widget. Attributes ---------- qt_viewer : QtViewer Contained viewer widget. """ with open(os.path.join(resources_dir, 'stylesheet.qss'), 'r') as f: raw_stylesheet = f.read() def __init__(self, qt_viewer, *, show=True): self.qt_viewer = qt_viewer self._qt_window = QMainWindow() self._qt_window.setUnifiedTitleAndToolBarOnMac(True) self._qt_center = QWidget(self._qt_window) self._qt_window.setCentralWidget(self._qt_center) self._qt_window.setWindowTitle(self.qt_viewer.viewer.title) self._qt_center.setLayout(QHBoxLayout()) self._status_bar = QStatusBar() self._qt_window.setStatusBar(self._status_bar) self._qt_window.closeEvent = self.closeEvent self.close = self._qt_window.close self._add_menubar() self._add_file_menu() self._add_view_menu() self._add_window_menu() self._add_help_menu() self._status_bar.showMessage('Ready') self._help = QLabel('') self._status_bar.addPermanentWidget(self._help) self._qt_center.layout().addWidget(self.qt_viewer) self._qt_center.layout().setContentsMargins(4, 0, 4, 0) self._update_palette(qt_viewer.viewer.palette) if self.qt_viewer.console.shell is not None: self._add_viewer_dock_widget(self.qt_viewer.dockConsole) self._add_viewer_dock_widget(self.qt_viewer.dockLayerControls) self._add_viewer_dock_widget(self.qt_viewer.dockLayerList) self.qt_viewer.viewer.events.status.connect(self._status_changed) self.qt_viewer.viewer.events.help.connect(self._help_changed) self.qt_viewer.viewer.events.title.connect(self._title_changed) self.qt_viewer.viewer.events.palette.connect( lambda event: self._update_palette(event.palette)) if show: self.show() def _add_menubar(self): self.main_menu = self._qt_window.menuBar() # Menubar shortcuts are only active when the menubar is visible. # Therefore, we set a global shortcut not associated with the menubar # to toggle visibility, *but*, in order to not shadow the menubar # shortcut, we disable it, and only enable it when the menubar is # hidden. See this stackoverflow link for details: # https://stackoverflow.com/questions/50537642/how-to-keep-the-shortcuts-of-a-hidden-widget-in-pyqt5 self._main_menu_shortcut = QShortcut(QKeySequence('Ctrl+M'), self._qt_window) self._main_menu_shortcut.activated.connect( self._toggle_menubar_visible) self._main_menu_shortcut.setEnabled(False) def _toggle_menubar_visible(self): """Toggle visibility of app menubar. This function also disables or enables a global keyboard shortcut to show the menubar, since menubar shortcuts are only available while the menubar is visible. """ if self.main_menu.isVisible(): self.main_menu.setVisible(False) self._main_menu_shortcut.setEnabled(True) else: self.main_menu.setVisible(True) self._main_menu_shortcut.setEnabled(False) def _add_file_menu(self): open_images = QAction('Open image(s)...', self._qt_window) open_images.setShortcut('Ctrl+O') open_images.setStatusTip('Open image file(s)') open_images.triggered.connect(self.qt_viewer._open_images) open_folder = QAction('Open Folder...', self._qt_window) open_folder.setShortcut('Ctrl-Shift-O') open_folder.setStatusTip( 'Open a folder of image file(s) or a zarr file') open_folder.triggered.connect(self.qt_viewer._open_folder) self.file_menu = self.main_menu.addMenu('&File') self.file_menu.addAction(open_images) self.file_menu.addAction(open_folder) def _add_view_menu(self): toggle_visible = QAction('Toggle menubar visibility', self._qt_window) toggle_visible.setShortcut('Ctrl+M') toggle_visible.setStatusTip('Hide Menubar') toggle_visible.triggered.connect(self._toggle_menubar_visible) self.view_menu = self.main_menu.addMenu('&View') self.view_menu.addAction(toggle_visible) def _add_window_menu(self): exit_action = QAction("Close window", self._qt_window) exit_action.setShortcut("Ctrl+W") exit_action.setStatusTip('Close napari window') exit_action.triggered.connect(self._qt_window.close) self.window_menu = self.main_menu.addMenu('&Window') self.window_menu.addAction(exit_action) def _add_help_menu(self): self.help_menu = self.main_menu.addMenu('&Help') about_action = QAction("napari info", self._qt_window) about_action.setShortcut("Ctrl+/") about_action.setStatusTip('About napari') about_action.triggered.connect( lambda e: QtAbout.showAbout(self.qt_viewer)) self.help_menu.addAction(about_action) about_keybindings = QAction("keybindings", self._qt_window) about_keybindings.setShortcut("Ctrl+Alt+/") about_keybindings.setShortcutContext(Qt.ApplicationShortcut) about_keybindings.setStatusTip('keybindings') about_keybindings.triggered.connect( self.qt_viewer.aboutKeybindings.toggle_visible) self.help_menu.addAction(about_keybindings) def add_dock_widget( self, widget: QWidget, *, name: str = '', area: str = 'bottom', allowed_areas=None, shortcut=None, ): """Convenience method to add a QDockWidget to the main window Parameters ---------- widget : QWidget `widget` will be added as QDockWidget's main widget. name : str, optional Name of dock widget to appear in window menu. area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. Returns ------- dock_widget : QtViewerDockWidget `dock_widget` that can pass viewer events. """ dock_widget = QtViewerDockWidget( self.qt_viewer, widget, name=name, area=area, allowed_areas=allowed_areas, shortcut=shortcut, ) self._add_viewer_dock_widget(dock_widget) return dock_widget def _add_viewer_dock_widget(self, dock_widget: QtViewerDockWidget): """Add a QtViewerDockWidget to the main window Parameters ---------- dock_widget : QtViewerDockWidget `dock_widget` will be added to the main window. """ dock_widget.setParent(self._qt_window) self._qt_window.addDockWidget(dock_widget.qt_area, dock_widget) action = dock_widget.toggleViewAction() action.setStatusTip(dock_widget.name) action.setText(dock_widget.name) if dock_widget.shortcut is not None: action.setShortcut(dock_widget.shortcut) self.window_menu.addAction(action) def remove_dock_widget(self, widget): """Removes specified dock widget. Parameters ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. """ if widget == 'all': for dw in self._qt_window.findChildren(QDockWidget): self._qt_window.removeDockWidget(dw) else: self._qt_window.removeDockWidget(widget) def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def show(self): """Resize, show, and bring forward the window.""" self._qt_window.resize(self._qt_window.layout().sizeHint()) self._qt_window.show() # We want to call Window._qt_window.raise_() in every case *except* # when instantiating a viewer within a gui_qt() context for the # _first_ time within the Qt app's lifecycle. # # `app_name` will be "napari" iff the application was instantiated in # gui_qt(). isActiveWindow() will be True if it is the second time a # _qt_window has been created. See #732 app_name = QApplication.instance().applicationName() if app_name != 'napari' or self._qt_window.isActiveWindow(): self._qt_window.raise_() # for macOS self._qt_window.activateWindow() # for Windows def _update_palette(self, palette): # set window styles which don't use the primary stylesheet # FIXME: this is a problem with the stylesheet not using properties self._status_bar.setStyleSheet( template( 'QStatusBar { background: {{ background }}; ' 'color: {{ text }}; }', **palette, )) self._qt_center.setStyleSheet( template('QWidget { background: {{ background }}; }', **palette)) self._qt_window.setStyleSheet(template(self.raw_stylesheet, **palette)) def _status_changed(self, event): """Update status bar. """ self._status_bar.showMessage(event.text) def _title_changed(self, event): """Update window title. """ self._qt_window.setWindowTitle(event.text) def _help_changed(self, event): """Update help message on status bar. """ self._help.setText(event.text) def screenshot(self): """Take currently displayed viewer and convert to an image array. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = self._qt_window.grab().toImage() return QImg2array(img) def closeEvent(self, event): # Forward close event to the console to trigger proper shutdown self.qt_viewer.console.shutdown() # if the viewer.QtDims object is playing an axis, we need to terminate the # AnimationThread before close, otherwise it will cauyse a segFault or Abort trap. # (calling stop() when no animation is occuring is also not a problem) self.qt_viewer.dims.stop() event.accept()
class SliceViewerDataView(QWidget): """The view for the data portion of the sliceviewer""" def __init__(self, presenter: IDataViewSubscriber, dims_info, can_normalise, parent=None, conf=None): super().__init__(parent) self.presenter = presenter self.image = None self.line_plots_active = False self.can_normalise = can_normalise self.nonortho_transform = None self.conf = conf self._line_plots = None self._image_info_tracker = None self._region_selection_on = False self._orig_lims = None # Dimension widget self.dimensions_layout = QGridLayout() self.dimensions = DimensionWidget(dims_info, parent=self) self.dimensions.dimensionsChanged.connect(self.presenter.dimensions_changed) self.dimensions.valueChanged.connect(self.presenter.slicepoint_changed) self.dimensions_layout.addWidget(self.dimensions, 1, 0, 1, 1) self.colorbar_layout = QVBoxLayout() self.colorbar_layout.setContentsMargins(0, 0, 0, 0) self.colorbar_layout.setSpacing(0) self.image_info_widget = ImageInfoWidget(self) self.image_info_widget.setToolTip("Information about the selected pixel") self.track_cursor = QCheckBox("Track Cursor", self) self.track_cursor.setToolTip( "Update the image readout table when the cursor is over the plot. " "If unticked the table will update only when the plot is clicked") self.dimensions_layout.setHorizontalSpacing(10) self.dimensions_layout.addWidget(self.track_cursor, 0, 1, Qt.AlignRight) self.dimensions_layout.addWidget(self.image_info_widget, 1, 1) self.track_cursor.setChecked(True) self.track_cursor.stateChanged.connect(self.on_track_cursor_state_change) # normalization options if can_normalise: self.norm_label = QLabel("Normalization") self.colorbar_layout.addWidget(self.norm_label) self.norm_opts = QComboBox() self.norm_opts.addItems(["None", "By bin width"]) self.norm_opts.setToolTip("Normalization options") self.colorbar_layout.addWidget(self.norm_opts) # MPL figure + colorbar self.fig = Figure() self.ax = None self.image = None self._grid_on = False self.fig.set_facecolor(self.palette().window().color().getRgbF()) self.canvas = SliceViewerCanvas(self.fig) self.canvas.mpl_connect('button_release_event', self.mouse_release) self.canvas.mpl_connect('button_press_event', self.presenter.canvas_clicked) self.colorbar_label = QLabel("Colormap") self.colorbar_layout.addWidget(self.colorbar_label) norm_scale = self.get_default_scale_norm() self.colorbar = ColorbarWidget(self, norm_scale) self.colorbar.cmap.setToolTip("Colormap options") self.colorbar.crev.setToolTip("Reverse colormap") self.colorbar.norm.setToolTip("Colormap normalisation options") self.colorbar.powerscale.setToolTip("Power colormap scale") self.colorbar.cmax.setToolTip("Colormap maximum limit") self.colorbar.cmin.setToolTip("Colormap minimum limit") self.colorbar.autoscale.setToolTip("Automatically changes colormap limits when zooming on the plot") self.colorbar_layout.addWidget(self.colorbar) self.colorbar.colorbarChanged.connect(self.update_data_clim) self.colorbar.scaleNormChanged.connect(self.scale_norm_changed) # make width larger to fit image readout table self.colorbar.setMaximumWidth(200) # MPL toolbar self.toolbar_layout = QHBoxLayout() self.mpl_toolbar = SliceViewerNavigationToolbar(self.canvas, self, False) self.mpl_toolbar.gridClicked.connect(self.toggle_grid) self.mpl_toolbar.linePlotsClicked.connect(self.on_line_plots_toggle) self.mpl_toolbar.regionSelectionClicked.connect(self.on_region_selection_toggle) self.mpl_toolbar.homeClicked.connect(self.on_home_clicked) self.mpl_toolbar.nonOrthogonalClicked.connect(self.on_non_orthogonal_axes_toggle) self.mpl_toolbar.zoomPanClicked.connect(self.presenter.zoom_pan_clicked) self.mpl_toolbar.zoomPanFinished.connect(self.on_data_limits_changed) self.toolbar_layout.addWidget(self.mpl_toolbar) # Status bar self.status_bar = QStatusBar(parent=self) self.status_bar.setStyleSheet('QStatusBar::item {border: None;}') # Hide spacers between button and label self.status_bar_label = QLabel() self.help_button = QToolButton() self.help_button.setText("?") self.status_bar.addWidget(self.help_button) self.status_bar.addWidget(self.status_bar_label) # layout layout = QGridLayout(self) layout.setSpacing(1) layout.addLayout(self.dimensions_layout, 0, 0, 1, 2) layout.addLayout(self.toolbar_layout, 1, 0, 1, 1) layout.addLayout(self.colorbar_layout, 1, 1, 3, 1) layout.addWidget(self.canvas, 2, 0, 1, 1) layout.addWidget(self.status_bar, 3, 0, 1, 1) layout.setRowStretch(2, 1) @property def grid_on(self): return self._grid_on @property def line_plotter(self): return self._line_plots @property def nonorthogonal_mode(self): return self.nonortho_transform is not None def create_axes_orthogonal(self, redraw_on_zoom=False): """Create a standard set of orthogonal axes :param redraw_on_zoom: If True then when scroll zooming the canvas is redrawn immediately """ self.clear_figure() self.nonortho_transform = None self.ax = self.fig.add_subplot(111, projection='mantid') self.enable_zoom_on_mouse_scroll(redraw_on_zoom) if self.grid_on: self.ax.grid(self.grid_on) if self.line_plots_active: self.add_line_plots() self.plot_MDH = self.plot_MDH_orthogonal self.canvas.draw_idle() def create_axes_nonorthogonal(self, transform): self.clear_figure() self.set_nonorthogonal_transform(transform) self.ax = CurveLinearSubPlot(self.fig, 1, 1, 1, grid_helper=GridHelperCurveLinear( (transform.tr, transform.inv_tr))) # don't redraw on zoom as the data is rebinned and has to be redrawn again anyway self.enable_zoom_on_mouse_scroll(redraw=False) self.set_grid_on() self.fig.add_subplot(self.ax) self.plot_MDH = self.plot_MDH_nonorthogonal self.canvas.draw_idle() def enable_zoom_on_mouse_scroll(self, redraw): """Enable zoom on scroll the mouse wheel for the created axes :param redraw: Pass through to redraw option in enable_zoom_on_scroll """ self.canvas.enable_zoom_on_scroll(self.ax, redraw=redraw, toolbar=self.mpl_toolbar, callback=self.on_data_limits_changed) def add_line_plots(self, toolcls, exporter): """Assuming line plots are currently disabled, enable them on the current figure The image axes must have been created first. :param toolcls: Use this class to handle creating the plots :param exporter: Object defining methods to export cuts/roi """ if self.line_plots_active: return self.line_plots_active = True self._line_plots = toolcls(LinePlots(self.ax, self.colorbar), exporter) self.status_bar_label.setText(self._line_plots.status_message()) self.canvas.setFocus() self.mpl_toolbar.set_action_checked(ToolItemText.LINEPLOTS, True, trigger=False) def switch_line_plots_tool(self, toolcls, exporter): """Assuming line plots are currently enabled then switch the tool used to generate the plot curves. :param toolcls: Use this class to handle creating the plots """ if not self.line_plots_active: return # Keep the same set of line plots axes but swap the selection tool plotter = self._line_plots.plotter plotter.delete_line_plot_lines() self._line_plots.disconnect() self._line_plots = toolcls(plotter, exporter) self.status_bar_label.setText(self._line_plots.status_message()) self.canvas.setFocus() self.canvas.draw_idle() def remove_line_plots(self): """Assuming line plots are currently enabled, remove them from the current figure """ if not self.line_plots_active: return self._line_plots.plotter.close() self.status_bar_label.clear() self._line_plots = None self.line_plots_active = False def plot_MDH_orthogonal(self, ws, **kwargs): """ clears the plot and creates a new one using a MDHistoWorkspace """ self.clear_image() self.image = self.ax.imshow(ws, origin='lower', aspect='auto', transpose=self.dimensions.transpose, norm=self.colorbar.get_norm(), **kwargs) # ensure the axes data limits are updated to match the # image. For example if the axes were zoomed and the # swap dimensions was clicked we need to restore the # appropriate extents to see the image in the correct place extent = self.image.get_extent() self.ax.set_xlim(extent[0], extent[1]) self.ax.set_ylim(extent[2], extent[3]) # Set the original data limits which get passed to the ImageInfoWidget so that # the mouse projection to data space is correct for MDH workspaces when zoomed/changing slices self._orig_lims = self.get_axes_limits() self.on_track_cursor_state_change(self.track_cursor_checked()) self.draw_plot() def plot_MDH_nonorthogonal(self, ws, **kwargs): self.clear_image() self.image = pcolormesh_nonorthogonal(self.ax, ws, self.nonortho_transform.tr, transpose=self.dimensions.transpose, norm=self.colorbar.get_norm(), **kwargs) self.on_track_cursor_state_change(self.track_cursor_checked()) # swapping dimensions in nonorthogonal mode currently resets back to the # full data limits as the whole axes has been recreated so we don't have # access to the original limits # pcolormesh clears any grid that was previously visible if self.grid_on: self.ax.grid(self.grid_on) self.draw_plot() def plot_matrix(self, ws, **kwargs): """ clears the plot and creates a new one using a MatrixWorkspace keeping the axes limits that have already been set """ # ensure view is correct if zoomed in while swapping dimensions # compute required extent and just have resampling imshow deal with it old_extent = None if self.image is not None: old_extent = self.image.get_extent() if self.image.transpose != self.dimensions.transpose: e1, e2, e3, e4 = old_extent old_extent = e3, e4, e1, e2 self.clear_image() self.image = self.ax.imshow(ws, origin='lower', aspect='auto', interpolation='none', transpose=self.dimensions.transpose, norm=self.colorbar.get_norm(), extent=old_extent, **kwargs) self.on_track_cursor_state_change(self.track_cursor_checked()) self.draw_plot() def clear_image(self): """Removes any image from the axes""" if self.image is not None: if self.line_plots_active: self._line_plots.plotter.delete_line_plot_lines() self.image_info_widget.cursorAt(DBLMAX, DBLMAX, DBLMAX) if hasattr(self.ax, "remove_artists_if"): self.ax.remove_artists_if(lambda art: art == self.image) else: self.image.remove() self.image = None def clear_figure(self): """Removes everything from the figure""" if self.line_plots_active: self._line_plots.plotter.close() self.line_plots_active = False self.image = None self.canvas.disable_zoom_on_scroll() self.fig.clf() self.ax = None def draw_plot(self): self.ax.set_title('') self.canvas.draw() if self.image: self.colorbar.set_mappable(self.image) self.colorbar.update_clim() self.mpl_toolbar.update() # clear nav stack if self.line_plots_active: self._line_plots.plotter.delete_line_plot_lines() self._line_plots.plotter.update_line_plot_labels() def export_region(self, limits, cut): """ React to a region selection that should be exported :param limits: 2-tuple of ((left, right), (bottom, top)) :param cut: A str denoting which cuts to export. """ self.presenter.export_region(limits, cut) def update_plot_data(self, data): """ This just updates the plot data without creating a new plot. The extents can change if the data has been rebinned. """ if self.nonortho_transform: self.image.set_array(data.T.ravel()) else: self.image.set_data(data.T) self.colorbar.update_clim() def track_cursor_checked(self): return self.track_cursor.isChecked() if self.track_cursor else False def on_track_cursor_state_change(self, state): """ Called to notify the current state of the track cursor box """ if self._image_info_tracker is not None: self._image_info_tracker.disconnect() if self._line_plots is not None and not self._region_selection_on: self._line_plots.disconnect() self._image_info_tracker = ImageInfoTracker(image=self.image, transform=self.nonortho_transform, do_transform=self.nonorthogonal_mode, widget=self.image_info_widget, cursor_transform=self._orig_lims) if state: self._image_info_tracker.connect() if self._line_plots and not self._region_selection_on: self._line_plots.connect() else: self._image_info_tracker.disconnect() if self._line_plots and not self._region_selection_on: self._line_plots.disconnect() def on_home_clicked(self): """Reset the view to encompass all of the data""" self.presenter.show_all_data_clicked() def on_line_plots_toggle(self, state): """Switch state of the line plots""" self.presenter.line_plots(state) def on_region_selection_toggle(self, state): """Switch state of the region selection""" self.presenter.region_selection(state) self._region_selection_on = state # If state is off and track cursor is on, make sure line plots are re-connected to move cursor if not state and self.track_cursor_checked(): if self._line_plots: self._line_plots.connect() def on_non_orthogonal_axes_toggle(self, state): """ Switch state of the non-orthognal axes on/off """ self.presenter.nonorthogonal_axes(state) def on_data_limits_changed(self): """ React to when the data limits have changed """ self.presenter.data_limits_changed() def deactivate_and_disable_tool(self, tool_text): """Deactivate a tool as if the control had been pressed and disable the functionality""" self.deactivate_tool(tool_text) self.disable_tool_button(tool_text) def activate_tool(self, tool_text): """Activate a given tool as if the control had been pressed""" self.mpl_toolbar.set_action_checked(tool_text, True) def deactivate_tool(self, tool_text): """Deactivate a given tool as if the tool button had been pressed""" self.mpl_toolbar.set_action_checked(tool_text, False) def enable_tool_button(self, tool_text): """Set a given tool button enabled so it can be interacted with""" self.mpl_toolbar.set_action_enabled(tool_text, True) def disable_tool_button(self, tool_text): """Set a given tool button disabled so it cannot be interacted with""" self.mpl_toolbar.set_action_enabled(tool_text, False) def get_axes_limits(self): """ Return the limits on the image axes transformed into the nonorthogonal frame if appropriate """ if self.image is None: return None else: xlim, ylim = self.ax.get_xlim(), self.ax.get_ylim() if self.nonorthogonal_mode: inv_tr = self.nonortho_transform.inv_tr # viewing axis y not aligned with plot axis xmin_p, ymax_p = inv_tr(xlim[0], ylim[1]) xmax_p, ymin_p = inv_tr(xlim[1], ylim[0]) xlim, ylim = (xmin_p, xmax_p), (ymin_p, ymax_p) return xlim, ylim def get_full_extent(self): """ Return the full extent of image - only applicable for plots of matrix workspaces """ if self.image and isinstance(self.image, samplingimage.SamplingImage): return self.image.get_full_extent() else: return None def set_axes_limits(self, xlim, ylim): """ Set the view limits on the image axes to the given extents. Assume the limits are in the orthogonal frame. :param xlim: 2-tuple of (xmin, xmax) :param ylim: 2-tuple of (ymin, ymax) """ self.ax.set_xlim(xlim) self.ax.set_ylim(ylim) def set_grid_on(self): """ If not visible sets the grid visibility """ if not self._grid_on: self._grid_on = True self.mpl_toolbar.set_action_checked(ToolItemText.GRID, state=self._grid_on) def set_nonorthogonal_transform(self, transform): """ Set the transform for nonorthogonal axes mode :param transform: An object with a tr method to transform from nonorthognal coordinates to display coordinates """ self.nonortho_transform = transform def show_temporary_status_message(self, msg, timeout_ms): """ Show a message in the status bar that disappears after a set period :param msg: A str message to display :param timeout_ms: Timeout in milliseconds to display the message for """ self.status_bar.showMessage(msg, timeout_ms) def toggle_grid(self, state): """ Toggle the visibility of the grid on the axes """ self._grid_on = state self.ax.grid(self._grid_on) self.canvas.draw_idle() def mouse_release(self, event): if event.inaxes != self.ax: return self.canvas.setFocus() if event.button == 1: self._image_info_tracker.on_cursor_at(event.xdata, event.ydata) if self.line_plots_active and not self._region_selection_on: self._line_plots.on_cursor_at(event.xdata, event.ydata) if event.button == 3: self.on_home_clicked() def deactivate_zoom_pan(self): self.deactivate_tool(ToolItemText.PAN) self.deactivate_tool(ToolItemText.ZOOM) def update_data_clim(self): self.image.set_clim(self.colorbar.colorbar.mappable.get_clim()) if self.line_plots_active: self._line_plots.plotter.update_line_plot_limits() self.canvas.draw_idle() def set_normalization(self, ws, **kwargs): normalize_by_bin_width, _ = get_normalize_by_bin_width(ws, self.ax, **kwargs) is_normalized = normalize_by_bin_width or ws.isDistribution() self.presenter.normalization = is_normalized if is_normalized: self.norm_opts.setCurrentIndex(1) else: self.norm_opts.setCurrentIndex(0) def get_default_scale_norm(self): scale = 'Linear' if self.conf is None: return scale if self.conf.has(SCALENORM): scale = self.conf.get(SCALENORM) if scale == 'Power' and self.conf.has(POWERSCALE): exponent = self.conf.get(POWERSCALE) scale = (scale, exponent) scale = "SymmetricLog10" if scale == 'Log' else scale return scale def scale_norm_changed(self): if self.conf is None: return scale = self.colorbar.norm.currentText() self.conf.set(SCALENORM, scale) if scale == 'Power': exponent = self.colorbar.powerscale_value self.conf.set(POWERSCALE, exponent)
class PythonFileInterpreter(QWidget): sig_editor_modified = Signal(bool) sig_filename_modified = Signal(str) def __init__(self, content=None, filename=None, parent=None): """ :param content: An optional string of content to pass to the editor :param filename: The file path where the content was read. :param parent: An optional parent QWidget """ super(PythonFileInterpreter, self).__init__(parent) # layout self.editor = CodeEditor("AlternateCSPythonLexer", self) self.status = QStatusBar(self) layout = QVBoxLayout() layout.addWidget(self.editor) layout.addWidget(self.status) self.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) self._setup_editor(content, filename) self._presenter = PythonFileInterpreterPresenter( self, PythonCodeExecution(content)) self.editor.modificationChanged.connect(self.sig_editor_modified) self.editor.fileNameChanged.connect(self.sig_filename_modified) @property def filename(self): return self.editor.fileName() def confirm_close(self): """Confirm the widget can be closed. If the editor contents are modified then a user can interject and cancel closing. :return: True if closing was considered successful, false otherwise """ return self.save(confirm=True) def abort(self): self._presenter.req_abort() def execute_async(self): self._presenter.req_execute_async() def save(self, confirm=False): if self.editor.isModified(): io = EditorIO(self.editor) return io.save_if_required(confirm) else: return True def set_editor_readonly(self, ro): self.editor.setReadOnly(ro) def set_status_message(self, msg): self.status.showMessage(msg) def _setup_editor(self, default_content, filename): editor = self.editor # use tabs not spaces for indentation editor.setIndentationsUseTabs(False) editor.setTabWidth(TAB_WIDTH) # show current editing line but in a softer color editor.setCaretLineBackgroundColor(CURRENTLINE_BKGD_COLOR) editor.setCaretLineVisible(True) # set a margin large enough for sensible file sizes < 1000 lines # and the progress marker font_metrics = QFontMetrics(self.font()) editor.setMarginWidth(1, font_metrics.averageCharWidth() * 3 + 20) # fill with content if supplied and set source filename if default_content is not None: editor.setText(default_content) if filename is not None: editor.setFileName(filename) # Default content does not count as a modification editor.setModified(False) editor.enableAutoCompletion(CodeEditor.AcsAll)
class ExtractAudioDialog(QDialog, Ui_extractAudioDialog): ''' Allows user to load a signal, processing it if necessary. ''' def __init__(self, parent, preferences, signal_model, default_signal=None, is_remux=False): super(ExtractAudioDialog, self).__init__(parent) self.setupUi(self) for f in COMPRESS_FORMAT_OPTIONS: self.audioFormat.addItem(f) self.showProbeButton.setIcon(qta.icon('fa5s.info')) self.showRemuxCommand.setIcon(qta.icon('fa5s.info')) self.inputFilePicker.setIcon(qta.icon('fa5s.folder-open')) self.targetDirPicker.setIcon(qta.icon('fa5s.folder-open')) self.calculateGainAdjustment.setIcon(qta.icon('fa5s.sliders-h')) self.limitRange.setIcon(qta.icon('fa5s.cut')) self.statusBar = QStatusBar() self.statusBar.setSizeGripEnabled(False) self.boxLayout.addWidget(self.statusBar) self.__preferences = preferences self.__signal_model = signal_model self.__default_signal = default_signal self.__executor = None self.__sound = None self.__extracted = False self.__stream_duration_micros = [] self.__is_remux = is_remux if self.__is_remux: self.setWindowTitle('Remux Audio') self.showRemuxCommand.setVisible(self.__is_remux) defaultOutputDir = self.__preferences.get(EXTRACTION_OUTPUT_DIR) if os.path.isdir(defaultOutputDir): self.targetDir.setText(defaultOutputDir) self.__reinit_fields() self.filterMapping.itemDoubleClicked.connect(self.show_mapping_dialog) self.inputDrop.callback = self.__handle_drop def __handle_drop(self, file): if file.startswith('file:/'): file = url2pathname(urlparse(file).path) if os.path.exists(file) and os.path.isfile(file): self.inputFile.setText(file) self.__probe_file() def show_remux_cmd(self): ''' Pops the ffmpeg command into a message box ''' if self.__executor is not None and self.__executor.filter_complex_script_content is not None: msg_box = QMessageBox() font = QFont() font.setFamily("Consolas") font.setPointSize(8) msg_box.setFont(font) msg_box.setText( self.__executor.filter_complex_script_content.replace( ';', ';\n')) msg_box.setIcon(QMessageBox.Information) msg_box.setWindowTitle('Remux Script') msg_box.exec() def show_mapping_dialog(self, item): ''' Shows the edit mapping dialog ''' if len(self.__signal_model) > 0 or self.__default_signal is not None: channel_idx = self.filterMapping.indexFromItem(item).row() mapped_filter = self.__executor.channel_to_filter.get( channel_idx, None) EditMappingDialog(self, channel_idx, self.__signal_model, self.__default_signal, mapped_filter, self.filterMapping.count(), self.map_filter_to_channel).exec() def map_filter_to_channel(self, channel_idx, signal): ''' updates the mapping of the given signal to the specified channel idx ''' if self.audioStreams.count() > 0 and self.__executor is not None: self.__executor.map_filter_to_channel(channel_idx, signal) self.__display_command_info() def selectFile(self): self.__reinit_fields() dialog = QFileDialog(parent=self) dialog.setFileMode(QFileDialog.ExistingFile) dialog.setWindowTitle('Select Audio or Video File') if dialog.exec(): selected = dialog.selectedFiles() if len(selected) > 0: self.inputFile.setText(selected[0]) self.__probe_file() def __reinit_fields(self): ''' Resets various fields and temporary state. ''' if self.__sound is not None: if not self.__sound.isFinished(): self.__sound.stop() self.__sound = None self.audioStreams.clear() self.videoStreams.clear() self.statusBar.clearMessage() self.__executor = None self.__extracted = False self.__stream_duration_micros = [] self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) self.buttonBox.button(QDialogButtonBox.Ok).setText( 'Remux' if self.__is_remux else 'Extract') if self.__is_remux: self.signalName.setVisible(False) self.signalNameLabel.setVisible(False) self.filterMapping.setVisible(True) self.filterMappingLabel.setVisible(True) self.includeOriginalAudio.setVisible(True) self.includeSubtitles.setVisible(True) self.gainOffset.setVisible(True) self.gainOffsetLabel.setVisible(True) self.gainOffset.setEnabled(False) self.gainOffsetLabel.setEnabled(False) self.calculateGainAdjustment.setVisible(True) self.calculateGainAdjustment.setEnabled(False) self.adjustRemuxedAudio.setVisible(True) self.remuxedAudioOffset.setVisible(True) self.adjustRemuxedAudio.setEnabled(False) self.remuxedAudioOffset.setEnabled(False) else: self.signalName.setText('') self.filterMapping.setVisible(False) self.filterMappingLabel.setVisible(False) self.includeOriginalAudio.setVisible(False) self.includeSubtitles.setVisible(False) self.gainOffset.setVisible(False) self.gainOffsetLabel.setVisible(False) self.calculateGainAdjustment.setVisible(False) self.adjustRemuxedAudio.setVisible(False) self.remuxedAudioOffset.setVisible(False) self.eacBitRate.setVisible(False) self.monoMix.setChecked(self.__preferences.get(EXTRACTION_MIX_MONO)) self.bassManage.setChecked(False) self.decimateAudio.setChecked( self.__preferences.get(EXTRACTION_DECIMATE)) self.includeOriginalAudio.setChecked( self.__preferences.get(EXTRACTION_INCLUDE_ORIGINAL)) self.includeSubtitles.setChecked( self.__preferences.get(EXTRACTION_INCLUDE_SUBTITLES)) if self.__preferences.get(EXTRACTION_COMPRESS): self.audioFormat.setCurrentText(COMPRESS_FORMAT_FLAC) else: self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE) self.monoMix.setEnabled(False) self.bassManage.setEnabled(False) self.decimateAudio.setEnabled(False) self.audioFormat.setEnabled(False) self.eacBitRate.setEnabled(False) self.includeOriginalAudio.setEnabled(False) self.includeSubtitles.setEnabled(False) self.inputFilePicker.setEnabled(True) self.audioStreams.setEnabled(False) self.videoStreams.setEnabled(False) self.channelCount.setEnabled(False) self.lfeChannelIndex.setEnabled(False) self.targetDirPicker.setEnabled(True) self.outputFilename.setEnabled(False) self.showProbeButton.setEnabled(False) self.filterMapping.setEnabled(False) self.filterMapping.clear() self.ffmpegCommandLine.clear() self.ffmpegCommandLine.setEnabled(False) self.ffmpegOutput.clear() self.ffmpegOutput.setEnabled(False) self.ffmpegProgress.setEnabled(False) self.ffmpegProgressLabel.setEnabled(False) self.ffmpegProgress.setValue(0) self.rangeFrom.setEnabled(False) self.rangeSeparatorLabel.setEnabled(False) self.rangeTo.setEnabled(False) self.limitRange.setEnabled(False) self.signalName.setEnabled(False) self.signalNameLabel.setEnabled(False) self.showRemuxCommand.setEnabled(False) def __probe_file(self): ''' Probes the specified file using ffprobe in order to discover the audio streams. ''' file_name = self.inputFile.text() self.__executor = Executor( file_name, self.targetDir.text(), mono_mix=self.monoMix.isChecked(), decimate_audio=self.decimateAudio.isChecked(), audio_format=self.audioFormat.currentText(), audio_bitrate=self.eacBitRate.value(), include_original=self.includeOriginalAudio.isChecked(), include_subtitles=self.includeSubtitles.isChecked(), signal_model=self.__signal_model if self.__is_remux else None, decimate_fs=self.__preferences.get(ANALYSIS_TARGET_FS), bm_fs=self.__preferences.get(BASS_MANAGEMENT_LPF_FS)) self.__executor.progress_handler = self.__handle_ffmpeg_process from app import wait_cursor with wait_cursor(f"Probing {file_name}"): self.__executor.probe_file() self.showProbeButton.setEnabled(True) if self.__executor.has_audio(): for a in self.__executor.audio_stream_data: text, duration_micros = parse_audio_stream( self.__executor.probe, a) self.audioStreams.addItem(text) self.__stream_duration_micros.append(duration_micros) self.videoStreams.addItem('No Video') for a in self.__executor.video_stream_data: self.videoStreams.addItem( parse_video_stream(self.__executor.probe, a)) if self.__is_remux and self.videoStreams.count() > 1: if self.audioFormat.findText(COMPRESS_FORMAT_EAC3) == -1: self.audioFormat.addItem(COMPRESS_FORMAT_EAC3) if self.__preferences.get(EXTRACTION_COMPRESS): self.audioFormat.setCurrentText(COMPRESS_FORMAT_EAC3) self.eacBitRate.setVisible(True) else: self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE) self.eacBitRate.setVisible(False) self.videoStreams.setCurrentIndex(1) self.adjustRemuxedAudio.setEnabled(True) self.remuxedAudioOffset.setEnabled(True) self.gainOffsetLabel.setEnabled(True) self.calculateGainAdjustment.setEnabled(True) self.audioStreams.setEnabled(True) self.videoStreams.setEnabled(True) self.channelCount.setEnabled(True) self.lfeChannelIndex.setEnabled(True) self.monoMix.setEnabled(True) self.bassManage.setEnabled(True) self.decimateAudio.setEnabled(True) self.audioFormat.setEnabled(True) self.eacBitRate.setEnabled(True) self.includeOriginalAudio.setEnabled(True) self.outputFilename.setEnabled(True) self.ffmpegCommandLine.setEnabled(True) self.filterMapping.setEnabled(True) self.limitRange.setEnabled(True) self.showRemuxCommand.setEnabled(True) self.__fit_options_to_selected() else: self.statusBar.showMessage( f"{file_name} contains no audio streams!") def onVideoStreamChange(self, idx): if idx == 0: eac_idx = self.audioFormat.findText(COMPRESS_FORMAT_EAC3) if eac_idx > -1: self.audioFormat.removeItem(eac_idx) if self.__preferences.get(EXTRACTION_COMPRESS): self.audioFormat.setCurrentText(COMPRESS_FORMAT_FLAC) else: self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE) else: if self.audioFormat.findText(COMPRESS_FORMAT_EAC3) == -1: self.audioFormat.addItem(COMPRESS_FORMAT_EAC3) if self.__preferences.get(EXTRACTION_COMPRESS): self.audioFormat.setCurrentText(COMPRESS_FORMAT_EAC3) else: self.audioFormat.setCurrentText(COMPRESS_FORMAT_NATIVE) self.updateFfmpegSpec() def updateFfmpegSpec(self): ''' Creates a new ffmpeg command for the specified channel layout. ''' if self.__executor is not None: self.__executor.update_spec(self.audioStreams.currentIndex(), self.videoStreams.currentIndex() - 1, self.monoMix.isChecked()) self.__init_channel_count_fields(self.__executor.channel_count, lfe_index=self.__executor.lfe_idx) self.__fit_options_to_selected() self.__display_command_info() self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True) def __fit_options_to_selected(self): # if we have no video then the output cannot contain multiple streams if self.videoStreams.currentIndex() == 0: self.includeOriginalAudio.setChecked(False) self.includeOriginalAudio.setEnabled(False) self.includeSubtitles.setChecked(False) self.includeSubtitles.setEnabled(False) else: self.includeOriginalAudio.setEnabled(True) self.includeSubtitles.setEnabled(True) # don't allow mono mix option if the stream is mono if self.channelCount.value() == 1: self.monoMix.setChecked(False) self.monoMix.setEnabled(False) self.bassManage.setChecked(False) self.bassManage.setEnabled(False) else: self.monoMix.setEnabled(True) # only allow bass management if we have an LFE channel if self.__executor.lfe_idx == 0: self.bassManage.setChecked(False) self.bassManage.setEnabled(False) else: self.bassManage.setEnabled(True) def __display_command_info(self): self.outputFilename.setText(self.__executor.output_file_name) self.ffmpegCommandLine.setPlainText(self.__executor.ffmpeg_cli) self.filterMapping.clear() for channel_idx, signal in self.__executor.channel_to_filter.items(): self.filterMapping.addItem( f"Channel {channel_idx + 1} -> {signal.name if signal else 'Passthrough'}" ) def updateOutputFilename(self): ''' Updates the output file name. ''' if self.__executor is not None: self.__executor.output_file_name = self.outputFilename.text() self.__display_command_info() def overrideFfmpegSpec(self, _): if self.__executor is not None: self.__executor.override('custom', self.channelCount.value(), self.lfeChannelIndex.value()) self.__fit_options_to_selected() self.__display_command_info() def toggle_decimate_audio(self): ''' Reacts to the change in decimation. ''' if self.audioStreams.count() > 0 and self.__executor is not None: self.__executor.decimate_audio = self.decimateAudio.isChecked() self.__display_command_info() def toggle_bass_manage(self): ''' Reacts to the change in bass management. ''' if self.audioStreams.count() > 0 and self.__executor is not None: self.__executor.bass_manage = self.bassManage.isChecked() self.__display_command_info() def change_audio_format(self, audio_format): ''' Reacts to the change in audio format. ''' if self.audioStreams.count() > 0 and self.__executor is not None: self.__executor.audio_format = audio_format if audio_format == COMPRESS_FORMAT_EAC3: self.eacBitRate.setVisible(True) self.__executor.audio_bitrate = self.eacBitRate.value() else: self.eacBitRate.setVisible(False) self.__display_command_info() def change_audio_bitrate(self, bitrate): ''' Allows the bitrate to be updated ''' if self.__executor is not None: self.__executor.audio_bitrate = bitrate self.__display_command_info() def update_original_audio(self): ''' Reacts to the change in original audio selection. ''' if self.audioStreams.count() > 0 and self.__executor is not None: if self.includeOriginalAudio.isChecked(): self.__executor.include_original_audio = True self.__executor.original_audio_offset = self.gainOffset.value() self.gainOffset.setEnabled(True) self.gainOffsetLabel.setEnabled(True) else: self.__executor.include_original_audio = False self.__executor.original_audio_offset = 0.0 self.gainOffset.setEnabled(False) self.gainOffsetLabel.setEnabled(False) self.__display_command_info() def toggle_include_subtitles(self): ''' Reacts to the change in subtitles selection. ''' if self.audioStreams.count() > 0 and self.__executor is not None: self.__executor.include_subtitles = self.includeSubtitles.isChecked( ) self.__display_command_info() def toggleMonoMix(self): ''' Reacts to the change in mono vs multichannel target. ''' if self.audioStreams.count() > 0 and self.__executor is not None: self.__executor.mono_mix = self.monoMix.isChecked() self.__display_command_info() def toggle_range(self): ''' toggles whether the range is enabled or not ''' if self.limitRange.isChecked(): self.limitRange.setText('Cut') if self.audioStreams.count() > 0: duration_ms = int(self.__stream_duration_micros[ self.audioStreams.currentIndex()] / 1000) if duration_ms > 1: from model.report import block_signals with block_signals(self.rangeFrom): self.rangeFrom.setTimeRange( QTime.fromMSecsSinceStartOfDay(0), QTime.fromMSecsSinceStartOfDay(duration_ms - 1)) self.rangeFrom.setTime( QTime.fromMSecsSinceStartOfDay(0)) self.rangeFrom.setEnabled(True) self.rangeSeparatorLabel.setEnabled(True) with block_signals(self.rangeTo): self.rangeTo.setEnabled(True) self.rangeTo.setTimeRange( QTime.fromMSecsSinceStartOfDay(1), QTime.fromMSecsSinceStartOfDay(duration_ms)) self.rangeTo.setTime( QTime.fromMSecsSinceStartOfDay(duration_ms)) else: self.limitRange.setText('Enable') self.rangeFrom.setEnabled(False) self.rangeSeparatorLabel.setEnabled(False) self.rangeTo.setEnabled(False) if self.__executor is not None: self.__executor.start_time_ms = 0 self.__executor.end_time_ms = 0 def update_start_time(self, time): ''' Reacts to start time changes ''' self.__executor.start_time_ms = time.msecsSinceStartOfDay() self.__display_command_info() def update_end_time(self, time): ''' Reacts to end time changes ''' msecs = time.msecsSinceStartOfDay() duration_ms = int( self.__stream_duration_micros[self.audioStreams.currentIndex()] / 1000) self.__executor.end_time_ms = msecs if msecs != duration_ms else 0 self.__display_command_info() def __init_channel_count_fields(self, channels, lfe_index=0): from model.report import block_signals with block_signals(self.lfeChannelIndex): self.lfeChannelIndex.setMaximum(channels) self.lfeChannelIndex.setValue(lfe_index) with block_signals(self.channelCount): self.channelCount.setMaximum(channels) self.channelCount.setValue(channels) def reject(self): ''' Stops any sound that is playing and exits. ''' if self.__sound is not None and not self.__sound.isFinished(): self.__sound.stop() self.__sound = None QDialog.reject(self) def accept(self): ''' Executes the ffmpeg command. ''' if self.__extracted is False: self.__extract() if not self.__is_remux: self.signalName.setEnabled(True) self.signalNameLabel.setEnabled(True) else: if self.__create_signals(): QDialog.accept(self) def __create_signals(self): ''' Creates signals from the output file just created. :return: True if we created the signals. ''' loader = AutoWavLoader(self.__preferences) output_file = self.__executor.get_output_path() if os.path.exists(output_file): from app import wait_cursor with wait_cursor(f"Creating signals for {output_file}"): logger.info(f"Creating signals for {output_file}") name_provider = lambda channel, channel_count: get_channel_name( self.signalName.text(), channel, channel_count, channel_layout_name=self.__executor.channel_layout_name) loader.load(output_file) signal = loader.auto_load(name_provider, self.decimateAudio.isChecked()) self.__signal_model.add(signal) return True else: msg_box = QMessageBox() msg_box.setText( f"Extracted audio file does not exist at: \n\n {output_file}") msg_box.setIcon(QMessageBox.Critical) msg_box.setWindowTitle('Unexpected Error') msg_box.exec() return False def __extract(self): ''' Triggers the ffmpeg command. ''' if self.__executor is not None: logger.info( f"Extracting {self.outputFilename.text()} from {self.inputFile.text()}" ) self.__executor.execute() def __handle_ffmpeg_process(self, key, value): ''' Handles progress reports from ffmpeg in order to communicate status via the progress bar. Used as a slot connected to a signal emitted by the AudioExtractor. :param key: the key. :param value: the value. ''' if key == SIGNAL_CONNECTED: self.__extract_started() elif key == 'out_time_ms': out_time_ms = int(value) if self.__executor.start_time_ms > 0 and self.__executor.end_time_ms > 0: total_micros = (self.__executor.end_time_ms - self.__executor.start_time_ms) * 1000 elif self.__executor.end_time_ms > 0: total_micros = self.__executor.end_time_ms * 1000 elif self.__executor.start_time_ms > 0: total_micros = self.__stream_duration_micros[ self.audioStreams.currentIndex()] - ( self.__executor.start_time_ms * 1000) else: total_micros = self.__stream_duration_micros[ self.audioStreams.currentIndex()] logger.debug( f"{self.inputFile.text()} -- {key}={value} vs {total_micros}") if total_micros > 0: progress = (out_time_ms / total_micros) * 100.0 self.ffmpegProgress.setValue(math.ceil(progress)) self.ffmpegProgress.setTextVisible(True) self.ffmpegProgress.setFormat(f"{round(progress, 2):.2f}%") elif key == SIGNAL_ERROR: self.__extract_complete(value, False) elif key == SIGNAL_COMPLETE: self.__extract_complete(value, True) def __extract_started(self): ''' Changes the UI to signal that extraction has started ''' self.inputFilePicker.setEnabled(False) self.audioStreams.setEnabled(False) self.videoStreams.setEnabled(False) self.channelCount.setEnabled(False) self.lfeChannelIndex.setEnabled(False) self.monoMix.setEnabled(False) self.bassManage.setEnabled(False) self.decimateAudio.setEnabled(False) self.audioFormat.setEnabled(False) self.eacBitRate.setEnabled(False) self.includeOriginalAudio.setEnabled(False) self.includeSubtitles.setEnabled(False) self.targetDirPicker.setEnabled(False) self.outputFilename.setEnabled(False) self.filterMapping.setEnabled(False) self.gainOffset.setEnabled(False) self.ffmpegOutput.setEnabled(True) self.ffmpegProgress.setEnabled(True) self.ffmpegProgressLabel.setEnabled(True) self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) palette = QPalette(self.ffmpegProgress.palette()) palette.setColor(QPalette.Highlight, QColor(Qt.green)) self.ffmpegProgress.setPalette(palette) def __extract_complete(self, result, success): ''' triggered when the extraction thread completes. ''' if self.__executor is not None: if success: logger.info( f"Extraction complete for {self.outputFilename.text()}") self.ffmpegProgress.setValue(100) self.__extracted = True if not self.__is_remux: self.signalName.setEnabled(True) self.signalNameLabel.setEnabled(True) self.signalName.setText( Path(self.outputFilename.text()).resolve().stem) self.buttonBox.button( QDialogButtonBox.Ok).setText('Create Signals') else: logger.error( f"Extraction failed for {self.outputFilename.text()}") palette = QPalette(self.ffmpegProgress.palette()) palette.setColor(QPalette.Highlight, QColor(Qt.red)) self.ffmpegProgress.setPalette(palette) self.statusBar.showMessage('Extraction failed', 5000) self.ffmpegOutput.setPlainText(result) self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True) audio = self.__preferences.get(EXTRACTION_NOTIFICATION_SOUND) if audio is not None: logger.debug(f"Playing {audio}") self.__sound = QSound(audio) self.__sound.play() def showProbeInDetail(self): ''' shows a tree widget containing the contents of the probe to allow the raw probe info to be visible. ''' if self.__executor is not None: ViewProbeDialog(self.inputFile.text(), self.__executor.probe, parent=self).exec() def setTargetDirectory(self): ''' Sets the target directory based on the user selection. ''' dialog = QFileDialog(parent=self) dialog.setFileMode(QFileDialog.DirectoryOnly) dialog.setWindowTitle(f"Select Output Directory") if dialog.exec(): selected = dialog.selectedFiles() if len(selected) > 0: self.targetDir.setText(selected[0]) if self.__executor is not None: self.__executor.target_dir = selected[0] self.__display_command_info() def override_filtered_gain_adjustment(self, val): ''' forces the gain adjustment to a specific value. ''' if self.__executor is not None: self.__executor.filtered_audio_offset = val def calculate_gain_adjustment(self): ''' Based on the filters applied, calculates the gain adjustment that is required to avoid clipping. ''' filts = list(set(self.__executor.channel_to_filter.values())) if len(filts) > 1 or filts[0] is not None: from app import wait_cursor with wait_cursor(): headroom = min([ min( self.__calc_headroom( x.filter_signal(filt=True, clip=False).samples), 0.0) for x in filts if x is not None ]) self.remuxedAudioOffset.setValue(headroom) @staticmethod def __calc_headroom(samples): return 20 * math.log(1.0 / np.nanmax(np.abs(samples)), 10)
class Window: """Application window that contains the menu bar and viewer. Parameters ---------- qt_viewer : QtViewer Contained viewer widget. Attributes ---------- qt_viewer : QtViewer Contained viewer widget. """ with open(os.path.join(resources_dir, 'stylesheet.qss'), 'r') as f: raw_stylesheet = f.read() def __init__(self, qt_viewer, *, show=True): self.qt_viewer = qt_viewer self._qt_window = QMainWindow() self._qt_window.setUnifiedTitleAndToolBarOnMac(True) self._qt_center = QWidget(self._qt_window) self._qt_window.setCentralWidget(self._qt_center) self._qt_window.setWindowTitle(self.qt_viewer.viewer.title) self._qt_center.setLayout(QHBoxLayout()) self._status_bar = QStatusBar() self._qt_window.setStatusBar(self._status_bar) self._qt_window.closeEvent = self.closeEvent self.close = self._qt_window.close self._add_menubar() self._add_file_menu() self._add_view_menu() self._add_window_menu() self._add_help_menu() self._status_bar.showMessage('Ready') self._help = QLabel('') self._status_bar.addPermanentWidget(self._help) self._qt_center.layout().addWidget(self.qt_viewer) self._qt_center.layout().setContentsMargins(4, 0, 4, 0) self._update_palette(qt_viewer.viewer.palette) self.qt_viewer.viewer.events.status.connect(self._status_changed) self.qt_viewer.viewer.events.help.connect(self._help_changed) self.qt_viewer.viewer.events.title.connect(self._title_changed) self.qt_viewer.viewer.events.palette.connect( lambda event: self._update_palette(event.palette)) if show: self.show() def _add_menubar(self): self.main_menu = self._qt_window.menuBar() # Menubar shortcuts are only active when the menubar is visible. # Therefore, we set a global shortcut not associated with the menubar # to toggle visibility, *but*, in order to not shadow the menubar # shortcut, we disable it, and only enable it when the menubar is # hidden. See this stackoverflow link for details: # https://stackoverflow.com/questions/50537642/how-to-keep-the-shortcuts-of-a-hidden-widget-in-pyqt5 self._main_menu_shortcut = QShortcut(QKeySequence('Ctrl+M'), self._qt_window) self._main_menu_shortcut.activated.connect( self._toggle_menubar_visible) self._main_menu_shortcut.setEnabled(False) def _toggle_menubar_visible(self): """Toggle visibility of app menubar. This function also disables or enables a global keyboard shortcut to show the menubar, since menubar shortcuts are only available while the menubar is visible. """ if self.main_menu.isVisible(): self.main_menu.setVisible(False) self._main_menu_shortcut.setEnabled(True) else: self.main_menu.setVisible(True) self._main_menu_shortcut.setEnabled(False) def _add_file_menu(self): open_images = QAction('Open image(s)...', self._qt_window) open_images.setShortcut('Ctrl+O') open_images.setStatusTip('Open image file(s)') open_images.triggered.connect(self.qt_viewer._open_images) open_folder = QAction('Open Folder...', self._qt_window) open_folder.setShortcut('Ctrl-Shift-O') open_folder.setStatusTip( 'Open a folder of image file(s) or a zarr file') open_folder.triggered.connect(self.qt_viewer._open_folder) self.file_menu = self.main_menu.addMenu('&File') self.file_menu.addAction(open_images) self.file_menu.addAction(open_folder) def _add_view_menu(self): toggle_visible = QAction('Toggle menubar visibility', self._qt_window) toggle_visible.setShortcut('Ctrl+M') toggle_visible.setStatusTip('Hide Menubar') toggle_visible.triggered.connect(self._toggle_menubar_visible) self.view_menu = self.main_menu.addMenu('&View') self.view_menu.addAction(toggle_visible) def _add_window_menu(self): exit_action = QAction("Close window", self._qt_window) exit_action.setShortcut("Ctrl+W") exit_action.setStatusTip('Close napari window') exit_action.triggered.connect(self._qt_window.close) self.window_menu = self.main_menu.addMenu('&Window') self.window_menu.addAction(exit_action) def _add_help_menu(self): self.help_menu = self.main_menu.addMenu('&Help') about_action = QAction("napari info", self._qt_window) about_action.setStatusTip('About napari') about_action.triggered.connect( lambda e: QtAbout.showAbout(self.qt_viewer)) self.help_menu.addAction(about_action) keybidings_action = QAction("keybindings", self._qt_window) keybidings_action.setShortcut("Ctrl+/") keybidings_action.setStatusTip('About keybindings') keybidings_action.triggered.connect( lambda e: QtAboutKeybindings.showAbout(self.qt_viewer)) self.help_menu.addAction(keybidings_action) def add_dock_widget(self, area: str = 'bottom', widget: QWidget = None, allowed_areas=None): """Convenience method to add a QDockWidget to the main window Parameters ---------- area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} widget : QWidget, optional If provided, `widget` will be added as QDockWidget's main widget allowed_areas : Qt.DockWidgetArea, optional Areas, relative to main window, that the new dock is allowed to go. """ areas = { 'left': Qt.LeftDockWidgetArea, 'right': Qt.RightDockWidgetArea, 'top': Qt.TopDockWidgetArea, 'bottom': Qt.BottomDockWidgetArea, } if area not in areas: raise ValueError(f'side argument must be in {list(areas.keys())}') dock_widget = QDockWidget(self._qt_window) dock_widget.setAllowedAreas(allowed_areas or (Qt.LeftDockWidgetArea | Qt.BottomDockWidgetArea | Qt.RightDockWidgetArea | Qt.TopDockWidgetArea)) dock_widget.setMinimumHeight(50) dock_widget.setMinimumWidth(50) if isinstance(widget, QWidget): dock_widget.setWidget(widget) widget.setParent(dock_widget) self._qt_window.addDockWidget(areas[area], dock_widget) return dock_widget def remove_dock_widget(self, widget): """Removes specified dock widget. Parameters ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. """ if widget == 'all': for dw in self._qt_window.findChildren(QDockWidget): self._qt_window.removeDockWidget(dw) else: self._qt_window.removeDockWidget(widget) def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def show(self): """Resize, show, and bring forward the window. """ self._qt_window.resize(self._qt_window.layout().sizeHint()) self._qt_window.show() def _update_palette(self, palette): # set window styles which don't use the primary stylesheet # FIXME: this is a problem with the stylesheet not using properties self._status_bar.setStyleSheet( template( 'QStatusBar { background: {{ background }}; ' 'color: {{ text }}; }', **palette, )) self._qt_center.setStyleSheet( template('QWidget { background: {{ background }}; }', **palette)) self._qt_window.setStyleSheet(template(self.raw_stylesheet, **palette)) def _status_changed(self, event): """Update status bar. """ self._status_bar.showMessage(event.text) def _title_changed(self, event): """Update window title. """ self._qt_window.setWindowTitle(event.text) def _help_changed(self, event): """Update help message on status bar. """ self._help.setText(event.text) def closeEvent(self, event): # Forward close event to the console to trigger proper shutdown self.qt_viewer.console.shutdown() # if the viewer.QtDims object is playing an axis, we need to terminate the # AnimationThread before close, otherwise it will cauyse a segFault or Abort trap. # (calling stop() when no animation is occuring is also not a problem) self.qt_viewer.dims.stop() event.accept()
class Window: """Application window that contains the menu bar and viewer. Parameters ---------- qt_viewer : QtViewer Contained viewer widget. Attributes ---------- file_menu : qtpy.QtWidgets.QMenu File menu. help_menu : qtpy.QtWidgets.QMenu Help menu. main_menu : qtpy.QtWidgets.QMainWindow.menuBar Main menubar. qt_viewer : QtViewer Contained viewer widget. view_menu : qtpy.QtWidgets.QMenu View menu. window_menu : qtpy.QtWidgets.QMenu Window menu. """ raw_stylesheet = get_stylesheet() def __init__(self, qt_viewer, *, show=True): self.qt_viewer = qt_viewer self._qt_window = QMainWindow() self._qt_window.setAttribute(Qt.WA_DeleteOnClose) self._qt_window.setUnifiedTitleAndToolBarOnMac(True) self._qt_center = QWidget(self._qt_window) self._qt_window.setCentralWidget(self._qt_center) self._qt_window.setWindowTitle(self.qt_viewer.viewer.title) self._qt_center.setLayout(QHBoxLayout()) self._status_bar = QStatusBar() self._qt_window.setStatusBar(self._status_bar) self._add_menubar() self._add_file_menu() self._add_view_menu() self._add_window_menu() self._add_plugins_menu() self._add_help_menu() self._status_bar.showMessage('Ready') self._help = QLabel('') self._status_bar.addPermanentWidget(self._help) self._qt_center.layout().addWidget(self.qt_viewer) self._qt_center.layout().setContentsMargins(4, 0, 4, 0) self._update_palette() self._add_viewer_dock_widget(self.qt_viewer.dockConsole) self._add_viewer_dock_widget(self.qt_viewer.dockLayerControls) self._add_viewer_dock_widget(self.qt_viewer.dockLayerList) self.qt_viewer.viewer.events.status.connect(self._status_changed) self.qt_viewer.viewer.events.help.connect(self._help_changed) self.qt_viewer.viewer.events.title.connect(self._title_changed) self.qt_viewer.viewer.events.palette.connect(self._update_palette) if show: self.show() def _add_menubar(self): """Add menubar to napari app.""" self.main_menu = self._qt_window.menuBar() # Menubar shortcuts are only active when the menubar is visible. # Therefore, we set a global shortcut not associated with the menubar # to toggle visibility, *but*, in order to not shadow the menubar # shortcut, we disable it, and only enable it when the menubar is # hidden. See this stackoverflow link for details: # https://stackoverflow.com/questions/50537642/how-to-keep-the-shortcuts-of-a-hidden-widget-in-pyqt5 self._main_menu_shortcut = QShortcut(QKeySequence('Ctrl+M'), self._qt_window) self._main_menu_shortcut.activated.connect( self._toggle_menubar_visible) self._main_menu_shortcut.setEnabled(False) def _toggle_menubar_visible(self): """Toggle visibility of app menubar. This function also disables or enables a global keyboard shortcut to show the menubar, since menubar shortcuts are only available while the menubar is visible. """ if self.main_menu.isVisible(): self.main_menu.setVisible(False) self._main_menu_shortcut.setEnabled(True) else: self.main_menu.setVisible(True) self._main_menu_shortcut.setEnabled(False) def _add_file_menu(self): """Add 'File' menu to app menubar.""" open_images = QAction('Open image(s)...', self._qt_window) open_images.setShortcut('Ctrl+O') open_images.setStatusTip('Open image file(s)') open_images.triggered.connect(self.qt_viewer._open_images) open_stack = QAction('Open image series as stack...', self._qt_window) open_stack.setShortcut('Ctrl+Alt+O') open_stack.setStatusTip('Open image files') open_stack.triggered.connect(self.qt_viewer._open_images_as_stack) open_folder = QAction('Open Folder...', self._qt_window) open_folder.setShortcut('Ctrl+Shift+O') open_folder.setStatusTip( 'Open a folder of image file(s) or a zarr file') open_folder.triggered.connect(self.qt_viewer._open_folder) screenshot = QAction('Screenshot', self._qt_window) screenshot.setShortcut('Ctrl+Alt+S') screenshot.setStatusTip( 'Save screenshot of current display, default .png') screenshot.triggered.connect(self.qt_viewer._save_screenshot) # OS X will rename this to Quit and put it in the app menu. exitAction = QAction('Exit', self._qt_window) exitAction.setShortcut('Ctrl+Q') exitAction.setMenuRole(QAction.QuitRole) def handle_exit(): # if the event loop was started in gui_qt() then the app will be # named 'napari'. Since the Qapp was started by us, just close it. if QApplication.applicationName() == 'napari': QApplication.closeAllWindows() QApplication.quit() # otherwise, something else created the QApp before us (such as # %gui qt IPython magic). If we quit the app in this case, then # *later* attemps to instantiate a napari viewer won't work until # the event loop is restarted with app.exec_(). So rather than # quit just close all the windows (and clear our app icon). else: QApplication.setWindowIcon(QIcon()) self.close() exitAction.triggered.connect(handle_exit) self.file_menu = self.main_menu.addMenu('&File') self.file_menu.addAction(open_images) self.file_menu.addAction(open_stack) self.file_menu.addAction(open_folder) self.file_menu.addAction(screenshot) self.file_menu.addAction(exitAction) def _add_view_menu(self): """Add 'View' menu to app menubar.""" toggle_visible = QAction('Toggle menubar visibility', self._qt_window) toggle_visible.setShortcut('Ctrl+M') toggle_visible.setStatusTip('Hide Menubar') toggle_visible.triggered.connect(self._toggle_menubar_visible) toggle_theme = QAction('Toggle theme', self._qt_window) toggle_theme.setShortcut('Ctrl+Shift+T') toggle_theme.setStatusTip('Toggle theme') toggle_theme.triggered.connect(self.qt_viewer.viewer._toggle_theme) self.view_menu = self.main_menu.addMenu('&View') self.view_menu.addAction(toggle_visible) self.view_menu.addAction(toggle_theme) def _add_window_menu(self): """Add 'Window' menu to app menubar.""" exit_action = QAction("Close window", self._qt_window) exit_action.setShortcut("Ctrl+W") exit_action.setStatusTip('Close napari window') exit_action.triggered.connect(self._qt_window.close) self.window_menu = self.main_menu.addMenu('&Window') self.window_menu.addAction(exit_action) def _add_plugins_menu(self): """Add 'Plugins' menu to app menubar.""" self.plugins_menu = self.main_menu.addMenu('&Plugins') list_plugins_action = QAction("List installed plugins...", self._qt_window) list_plugins_action.setStatusTip('List installed plugins') list_plugins_action.triggered.connect(self._show_plugin_list) self.plugins_menu.addAction(list_plugins_action) order_plugin_action = QAction("Plugin call order...", self._qt_window) order_plugin_action.setStatusTip('Change call order for plugins') order_plugin_action.triggered.connect(self._show_plugin_sorter) self.plugins_menu.addAction(order_plugin_action) report_plugin_action = QAction("Plugin errors...", self._qt_window) report_plugin_action.setStatusTip( 'Review stack traces for plugin exceptions and notify developers') report_plugin_action.triggered.connect(self._show_plugin_err_reporter) self.plugins_menu.addAction(report_plugin_action) def _show_plugin_list(self): """Show dialog with a table of installed plugins and metadata.""" from ..plugins import plugin_manager dialog = QDialog(self._qt_window) dialog.setMaximumHeight(800) dialog.setMaximumWidth(1280) layout = QVBoxLayout() # maybe someday add a search bar here? title = QLabel("Installed Plugins") title.setObjectName("h2") layout.addWidget(title) # get metadata for successfully registered plugins data = [ v for k, v in plugin_manager._plugin_meta.items() if k in plugin_manager._name2plugin ] # create a table for it dialog.table = QtDictTable( self._qt_window, data, headers=[ 'plugin', 'package', 'version', 'url', 'author', 'license', ], min_section_width=60, ) dialog.table.setObjectName("pluginTable") dialog.table.horizontalHeader().setObjectName("pluginTableHeader") dialog.table.verticalHeader().setObjectName("pluginTableHeader") dialog.table.setGridStyle(Qt.NoPen) # prevent editing of table dialog.table.setEditTriggers(QAbstractItemView.NoEditTriggers) layout.addWidget(dialog.table) dialog.setLayout(layout) dialog.setAttribute(Qt.WA_DeleteOnClose) self._plugin_list = dialog dialog.exec_() def _show_plugin_sorter(self): """Show dialog that allows users to sort the call order of plugins.""" plugin_sorter = QtPluginSorter(parent=self._qt_window) dock_widget = self.add_dock_widget(plugin_sorter, name='Plugin Sorter', area="right") plugin_sorter.finished.connect(dock_widget.close) plugin_sorter.finished.connect(plugin_sorter.deleteLater) plugin_sorter.finished.connect(dock_widget.deleteLater) def _show_plugin_err_reporter(self): """Show dialog that allows users to review and report plugin errors.""" plugin_sorter = QtPluginErrReporter(parent=self._qt_window) plugin_sorter.exec_() def _add_help_menu(self): """Add 'Help' menu to app menubar.""" self.help_menu = self.main_menu.addMenu('&Help') about_action = QAction("napari info", self._qt_window) about_action.setShortcut("Ctrl+/") about_action.setStatusTip('About napari') about_action.triggered.connect( lambda e: QtAbout.showAbout(self.qt_viewer)) self.help_menu.addAction(about_action) about_key_bindings = QAction("Show key bindings", self._qt_window) about_key_bindings.setShortcut("Ctrl+Alt+/") about_key_bindings.setShortcutContext(Qt.ApplicationShortcut) about_key_bindings.setStatusTip('key_bindings') about_key_bindings.triggered.connect( self.qt_viewer.show_key_bindings_dialog) self.help_menu.addAction(about_key_bindings) def add_dock_widget( self, widget: QWidget, *, name: str = '', area: str = 'bottom', allowed_areas=None, shortcut=None, ): """Convenience method to add a QDockWidget to the main window Parameters ---------- widget : QWidget `widget` will be added as QDockWidget's main widget. name : str, optional Name of dock widget to appear in window menu. area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. Returns ------- dock_widget : QtViewerDockWidget `dock_widget` that can pass viewer events. """ dock_widget = QtViewerDockWidget( self.qt_viewer, widget, name=name, area=area, allowed_areas=allowed_areas, shortcut=shortcut, ) self._add_viewer_dock_widget(dock_widget) return dock_widget def _add_viewer_dock_widget(self, dock_widget: QtViewerDockWidget): """Add a QtViewerDockWidget to the main window Parameters ---------- dock_widget : QtViewerDockWidget `dock_widget` will be added to the main window. """ dock_widget.setParent(self._qt_window) self._qt_window.addDockWidget(dock_widget.qt_area, dock_widget) action = dock_widget.toggleViewAction() action.setStatusTip(dock_widget.name) action.setText(dock_widget.name) if dock_widget.shortcut is not None: action.setShortcut(dock_widget.shortcut) self.window_menu.addAction(action) def remove_dock_widget(self, widget): """Removes specified dock widget. Parameters ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. """ if widget == 'all': for dw in self._qt_window.findChildren(QDockWidget): self._qt_window.removeDockWidget(dw) else: self._qt_window.removeDockWidget(widget) def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def show(self): """Resize, show, and bring forward the window.""" self._qt_window.resize(self._qt_window.layout().sizeHint()) self._qt_window.show() # We want to call Window._qt_window.raise_() in every case *except* # when instantiating a viewer within a gui_qt() context for the # _first_ time within the Qt app's lifecycle. # # `app_name` will be "napari" iff the application was instantiated in # gui_qt(). isActiveWindow() will be True if it is the second time a # _qt_window has been created. See #732 app_name = QApplication.instance().applicationName() if app_name != 'napari' or self._qt_window.isActiveWindow(): self._qt_window.raise_() # for macOS self._qt_window.activateWindow() # for Windows def _update_palette(self, event=None): """Update widget color palette.""" # set window styles which don't use the primary stylesheet # FIXME: this is a problem with the stylesheet not using properties palette = self.qt_viewer.viewer.palette self._status_bar.setStyleSheet( template( 'QStatusBar { background: {{ background }}; ' 'color: {{ text }}; }', **palette, )) self._qt_center.setStyleSheet( template('QWidget { background: {{ background }}; }', **palette)) self._qt_window.setStyleSheet(template(self.raw_stylesheet, **palette)) def _status_changed(self, event): """Update status bar. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._status_bar.showMessage(event.text) def _title_changed(self, event): """Update window title. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._qt_window.setWindowTitle(event.text) def _help_changed(self, event): """Update help message on status bar. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._help.setText(event.text) def screenshot(self, path=None): """Take currently displayed viewer and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = self._qt_window.grab().toImage() if path is not None: imsave(path, QImg2array(img)) # scikit-image imsave method return QImg2array(img) def close(self): """Close the viewer window and cleanup sub-widgets.""" # on some versions of Darwin, exiting while fullscreen seems to tickle # some bug deep in NSWindow. This forces the fullscreen keybinding # test to complete its draw cycle, then pop back out of fullscreen. if self._qt_window.isFullScreen(): self._qt_window.showNormal() for i in range(8): time.sleep(0.1) QApplication.processEvents() self.qt_viewer.close() self._qt_window.close() del self._qt_window
class PythonFileInterpreter(QWidget): sig_editor_modified = Signal(bool) sig_filename_modified = Signal(str) def __init__(self, content=None, filename=None, parent=None): """ :param content: An optional string of content to pass to the editor :param filename: The file path where the content was read. :param parent: An optional parent QWidget """ super(PythonFileInterpreter, self).__init__(parent) # layout self.editor = CodeEditor("AlternateCSPythonLexer", self) self.status = QStatusBar(self) layout = QVBoxLayout() layout.addWidget(self.editor) layout.addWidget(self.status) self.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) self._setup_editor(content, filename) self._presenter = PythonFileInterpreterPresenter(self, PythonCodeExecution(content)) self.editor.modificationChanged.connect(self.sig_editor_modified) self.editor.fileNameChanged.connect(self.sig_filename_modified) @property def filename(self): return self.editor.fileName() def confirm_close(self): """Confirm the widget can be closed. If the editor contents are modified then a user can interject and cancel closing. :return: True if closing was considered successful, false otherwise """ return self.save(confirm=True) def abort(self): self._presenter.req_abort() def execute_async(self): self._presenter.req_execute_async() def save(self, confirm=False): if self.editor.isModified(): io = EditorIO(self.editor) return io.save_if_required(confirm) else: return True def set_editor_readonly(self, ro): self.editor.setReadOnly(ro) def set_status_message(self, msg): self.status.showMessage(msg) def _setup_editor(self, default_content, filename): editor = self.editor # use tabs not spaces for indentation editor.setIndentationsUseTabs(False) editor.setTabWidth(TAB_WIDTH) # show current editing line but in a softer color editor.setCaretLineBackgroundColor(CURRENTLINE_BKGD_COLOR) editor.setCaretLineVisible(True) # set a margin large enough for sensible file sizes < 1000 lines # and the progress marker font_metrics = QFontMetrics(self.font()) editor.setMarginWidth(1, font_metrics.averageCharWidth() * 3 + 20) # fill with content if supplied and set source filename if default_content is not None: editor.setText(default_content) if filename is not None: editor.setFileName(filename) # Default content does not count as a modification editor.setModified(False) editor.enableAutoCompletion(CodeEditor.AcsAll)
class PythonFileInterpreter(QWidget): sig_editor_modified = Signal(bool) sig_filename_modified = Signal(str) def __init__(self, content=None, filename=None, parent=None): """ :param content: An optional string of content to pass to the editor :param filename: The file path where the content was read. :param parent: An optional parent QWidget """ super(PythonFileInterpreter, self).__init__(parent) # layout self.editor = CodeEditor("AlternateCSPythonLexer", self) # Clear QsciScintilla key bindings that may override PyQt's bindings self.clear_key_binding("Ctrl+/") self.status = QStatusBar(self) self.layout = QVBoxLayout() self.layout.addWidget(self.editor) self.layout.addWidget(self.status) self.setLayout(self.layout) self.layout.setContentsMargins(0, 0, 0, 0) self._setup_editor(content, filename) self.setAttribute(Qt.WA_DeleteOnClose, True) self._presenter = PythonFileInterpreterPresenter( self, PythonCodeExecution(content)) self.editor.modificationChanged.connect(self.sig_editor_modified) self.editor.fileNameChanged.connect(self.sig_filename_modified) self.find_replace_dialog = None self.find_replace_dialog_shown = False def closeEvent(self, event): self.deleteLater() if self.find_replace_dialog: self.find_replace_dialog.close() super(PythonFileInterpreter, self).closeEvent(event) def show_find_replace_dialog(self): if self.find_replace_dialog is None: self.find_replace_dialog = EmbeddedFindReplaceDialog( self, self.editor) self.layout.insertWidget(0, self.find_replace_dialog.view) self.find_replace_dialog.show() def hide_find_replace_dialog(self): if self.find_replace_dialog is not None: self.find_replace_dialog.hide() @property def filename(self): return self.editor.fileName() def confirm_close(self): """Confirm the widget can be closed. If the editor contents are modified then a user can interject and cancel closing. :return: True if closing was considered successful, false otherwise """ return self.save(confirm=True) def abort(self): self._presenter.req_abort() def execute_async(self): self._presenter.req_execute_async() def save(self, confirm=False): if self.editor.isModified(): io = EditorIO(self.editor) return io.save_if_required(confirm) else: return True def set_editor_readonly(self, ro): self.editor.setReadOnly(ro) def set_status_message(self, msg): self.status.showMessage(msg) def replace_tabs_with_spaces(self): self.replace_text(TAB_CHAR, SPACE_CHAR * TAB_WIDTH) def replace_text(self, match_text, replace_text): if self.editor.selectedText() == '': self.editor.selectAll() new_text = self.editor.selectedText().replace(match_text, replace_text) self.editor.replaceSelectedText(new_text) def replace_spaces_with_tabs(self): self.replace_text(SPACE_CHAR * TAB_WIDTH, TAB_CHAR) def set_whitespace_visible(self): self.editor.setWhitespaceVisibility(CodeEditor.WsVisible) def set_whitespace_invisible(self): self.editor.setWhitespaceVisibility(CodeEditor.WsInvisible) def clear_key_binding(self, key_str): """Clear a keyboard shortcut bound to a Scintilla command""" self.editor.clearKeyBinding(key_str) def toggle_comment(self): if self.editor.selectedText() == '': # If nothing selected, do nothing return # Note selection indices to restore highlighting later selection_idxs = list(self.editor.getSelection()) # Expand selection from first character on start line to end char on last line line_end_pos = len( self.editor.text().split('\n')[selection_idxs[2]].rstrip()) line_selection_idxs = [ selection_idxs[0], 0, selection_idxs[2], line_end_pos ] self.editor.setSelection(*line_selection_idxs) selected_lines = self.editor.selectedText().split('\n') if self._are_comments(selected_lines) is True: toggled_lines = self._uncomment_lines(selected_lines) # Track deleted characters to keep highlighting consistent selection_idxs[1] -= 2 selection_idxs[-1] -= 2 else: toggled_lines = self._comment_lines(selected_lines) selection_idxs[1] += 2 selection_idxs[-1] += 2 # Replace lines with commented/uncommented lines self.editor.replaceSelectedText('\n'.join(toggled_lines)) # Restore highlighting self.editor.setSelection(*selection_idxs) def _comment_lines(self, lines): for i in range(len(lines)): lines[i] = '# ' + lines[i] return lines def _uncomment_lines(self, lines): for i in range(len(lines)): uncommented_line = lines[i].replace('# ', '', 1) if uncommented_line == lines[i]: uncommented_line = lines[i].replace('#', '', 1) lines[i] = uncommented_line return lines def _are_comments(self, code_lines): for line in code_lines: if line.strip(): if not line.strip().startswith('#'): return False return True def _setup_editor(self, default_content, filename): editor = self.editor # use tabs not spaces for indentation editor.setIndentationsUseTabs(False) editor.setTabWidth(TAB_WIDTH) # show current editing line but in a softer color editor.setCaretLineBackgroundColor(CURRENTLINE_BKGD_COLOR) editor.setCaretLineVisible(True) # set a margin large enough for sensible file sizes < 1000 lines # and the progress marker font_metrics = QFontMetrics(self.font()) editor.setMarginWidth(1, font_metrics.averageCharWidth() * 3 + 20) # fill with content if supplied and set source filename if default_content is not None: editor.setText(default_content) if filename is not None: editor.setFileName(filename) # Default content does not count as a modification editor.setModified(False) editor.enableAutoCompletion(CodeEditor.AcsAll)
class PythonFileInterpreter(QWidget): sig_editor_modified = Signal(bool) sig_filename_modified = Signal(str) sig_progress = Signal(int) sig_exec_error = Signal(object) sig_exec_success = Signal(object) def __init__(self, font=None, content=None, filename=None, parent=None): """ :param font: A reference to the font to be used by the editor. If not supplied use the system default :param content: An optional string of content to pass to the editor :param filename: The file path where the content was read. :param parent: An optional parent QWidget """ super(PythonFileInterpreter, self).__init__(parent) self.parent = parent # layout font = font if font is not None else QFont() self.editor = CodeEditor("AlternateCSPython", font, self) self.find_replace_dialog = None self.status = QStatusBar(self) self.layout = QVBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) self.layout.addWidget(self.editor) self.layout.addWidget(self.status) self.setLayout(self.layout) self._setup_editor(content, filename) self._presenter = PythonFileInterpreterPresenter(self, PythonCodeExecution(content)) self.code_commenter = CodeCommenter(self.editor) self.editor.modificationChanged.connect(self.sig_editor_modified) self.editor.fileNameChanged.connect(self.sig_filename_modified) self.setAttribute(Qt.WA_DeleteOnClose, True) # Connect the model signals to the view's signals so they can be accessed from outside the MVP self._presenter.model.sig_exec_progress.connect(self.sig_progress) self._presenter.model.sig_exec_error.connect(self.sig_exec_error) self._presenter.model.sig_exec_success.connect(self.sig_exec_success) def closeEvent(self, event): self.deleteLater() if self.find_replace_dialog: self.find_replace_dialog.close() super(PythonFileInterpreter, self).closeEvent(event) def show_find_replace_dialog(self): if self.find_replace_dialog is None: self.find_replace_dialog = EmbeddedFindReplaceDialog(self, self.editor) self.layout.insertWidget(0, self.find_replace_dialog.view) self.find_replace_dialog.show() def hide_find_replace_dialog(self): if self.find_replace_dialog is not None: self.find_replace_dialog.hide() @property def filename(self): return self.editor.fileName() def confirm_close(self): """Confirm the widget can be closed. If the editor contents are modified then a user can interject and cancel closing. :return: True if closing was considered successful, false otherwise """ return self.save(prompt_for_confirmation=self.parent.confirm_on_save) def abort(self): self._presenter.req_abort() def execute_async(self, ignore_selection=False): return self._presenter.req_execute_async(ignore_selection) def execute_async_blocking(self): self._presenter.req_execute_async_blocking() def save(self, prompt_for_confirmation=False, force_save=False): if self.editor.isModified(): io = EditorIO(self.editor) return io.save_if_required(prompt_for_confirmation, force_save) else: return True def save_as(self): io = EditorIO(self.editor) new_filename = io.ask_for_filename() if new_filename: return io.write(save_as=new_filename), new_filename else: return False, None def set_editor_readonly(self, ro): self.editor.setReadOnly(ro) def set_status_message(self, msg): self.status.showMessage(msg) def replace_tabs_with_spaces(self): self.replace_text(TAB_CHAR, SPACE_CHAR * TAB_WIDTH) def replace_text(self, match_text, replace_text): if self.editor.selectedText() == '': self.editor.selectAll() new_text = self.editor.selectedText().replace(match_text, replace_text) self.editor.replaceSelectedText(new_text) def replace_spaces_with_tabs(self): self.replace_text(SPACE_CHAR * TAB_WIDTH, TAB_CHAR) def set_whitespace_visible(self): self.editor.setWhitespaceVisibility(CodeEditor.WsVisible) def set_whitespace_invisible(self): self.editor.setWhitespaceVisibility(CodeEditor.WsInvisible) def toggle_comment(self): self.code_commenter.toggle_comment() def _setup_editor(self, default_content, filename): editor = self.editor # Clear default QsciScintilla key bindings that we want to allow # to be users of this class self.clear_key_binding("Ctrl+/") # use tabs not spaces for indentation editor.setIndentationsUseTabs(False) editor.setTabWidth(TAB_WIDTH) # show current editing line but in a softer color editor.setCaretLineBackgroundColor(CURRENTLINE_BKGD_COLOR) editor.setCaretLineVisible(True) # set a margin large enough for sensible file sizes < 1000 lines # and the progress marker font_metrics = QFontMetrics(self.font()) editor.setMarginWidth(1, font_metrics.averageCharWidth() * 3 + 20) # fill with content if supplied and set source filename if default_content is not None: editor.setText(default_content) if filename is not None: editor.setFileName(filename) # Default content does not count as a modification editor.setModified(False) editor.enableAutoCompletion(CodeEditor.AcsAll) def clear_key_binding(self, key_str): """Clear a keyboard shortcut bound to a Scintilla command""" self.editor.clearKeyBinding(key_str)
class MainWindow(QMainWindow): """OpenBurn's main window""" title = "OpenBurn" def __init__(self): super(MainWindow, self).__init__() self.setWindowTitle(self.title) self.setGeometry(100, 100, 800, 600) self.setWindowIcon(QIcon(RESOURCE_PATH + "icons/nakka-finocyl.gif")) self.create_default_widgets() self.setup_ui() def create_default_widgets(self): """Creates static widgets such as menubar and statusbar""" def create_menubar(): """Create menu bar and populate it with sub menu actions""" def file_menu(): """Create a file submenu""" self.file_sub_menu = self.menubar.addMenu('File') self.open_action = QAction('Open File', self) self.open_action.setStatusTip('Open a new design') self.open_action.setShortcut('CTRL+O') # self.open_action.triggered.connect(self.open_file) self.exit_action = QAction('Exit', self) self.exit_action.setStatusTip('Exit the application.') self.exit_action.setShortcut('CTRL+Q') self.exit_action.triggered.connect(QApplication.quit) self.file_sub_menu.addAction(self.open_action) self.file_sub_menu.addAction(self.exit_action) def edit_menu(): self.edit_dub_menu = self.menubar.addMenu('Edit') def tools_menu(): self.edit_dub_menu = self.menubar.addMenu('Tools') def help_menu(): """Create help submenu""" self.help_sub_menu = self.menubar.addMenu('Help') self.about_action = QAction('About', self) self.about_action.setStatusTip('About the application.') self.about_action.setShortcut('CTRL+H') self.about_action.triggered.connect(self.about_dialog.exec_) self.help_sub_menu.addAction(self.about_action) self.menubar = QMenuBar(self) file_menu() edit_menu() tools_menu() help_menu() def create_statusbar(): self.statusbar = QStatusBar(self) self.statusbar.showMessage("Ready", 0) self.about_dialog = AboutDialog(self) create_menubar() self.setMenuBar(self.menubar) create_statusbar() self.setStatusBar(self.statusbar) def setup_ui(self): """setup the tab widget UI""" self.tab_widget = QTabWidget() self.tab_widget.addTab(DesignTab(), "Design") self.tab_widget.addTab(QWidget(), "Simulation") self.tab_widget.addTab(QWidget(), "Propellants") self.layout = QVBoxLayout() self.layout.addWidget(self.tab_widget) self.frame = QFrame() self.frame.setLayout(self.layout) self.setCentralWidget(self.frame)