From 2295b27290a39b2c58f8f5c2f9928013cb6e601a Mon Sep 17 00:00:00 2001 From: Lawrence Hudson Date: Tue, 26 Apr 2016 10:34:39 +0100 Subject: [PATCH 1/3] [#288] Minimap navigator --- CHANGELOG.md | 1 + inselect/gui/info_widget.py | 29 +-- inselect/gui/main_window.py | 293 +++++++++++++++---------- inselect/gui/navigator.py | 105 +++++++++ inselect/gui/popup_panel.py | 17 ++ inselect/gui/utils.py | 2 +- inselect/gui/views/boxes/boxes_view.py | 64 ++++-- inselect/gui/views/metadata.py | 48 ++-- 8 files changed, 388 insertions(+), 171 deletions(-) create mode 100644 inselect/gui/navigator.py create mode 100644 inselect/gui/popup_panel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c5be2cb..8ce769a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ This is an overview of major changes. Refer to the git repository for a full log Version 0.1.28 ------------- +- Fixed #288 - Minimap navigator - Fixed #284 - Document info view to contain links - Fixed #281 - Don't make images read-only - Fixed #204 - Improvements to subsegment dots diff --git a/inselect/gui/info_widget.py b/inselect/gui/info_widget.py index f1c2a1b..7055428 100644 --- a/inselect/gui/info_widget.py +++ b/inselect/gui/info_widget.py @@ -9,7 +9,7 @@ from inselect.lib.utils import format_dt_display -from .toggle_widget_label import ToggleWidgetLabel +from .popup_panel import PopupPanel from .utils import report_to_user @@ -20,8 +20,7 @@ def reveal_path(path): if sys.platform.startswith("win"): explorer = QProcessEnvironment.systemEnvironment().searchInPath("explorer.exe") if not explorer: - # Handle gracefully - pass + raise ValueError('Explorer could not be located') else: if not path.is_dir(): arg = u"/select,{0}".format(path) @@ -69,17 +68,11 @@ class BoldLabel(QLabel): pass -class InfoWidget(QGroupBox): +class InfoWidget(PopupPanel): """Shows information about the document and the scanned image """ STYLESHEET = """ - ToggleWidgetLabel { - text-decoration: none; - font-weight: bold; - color: black; - } - BoldLabel { font-weight: bold; } @@ -89,12 +82,7 @@ class InfoWidget(QGroupBox): } """ - def __init__(self, parent=None): - super(InfoWidget, self).__init__(parent) - - self.setStyleSheet(self.STYLESHEET) - layout = QFormLayout() self._document_path = RevealPathLabel() @@ -136,15 +124,10 @@ def __init__(self, parent=None): labels_widget.setLayout(layout) labels_widget.setVisible(False) - # Show the labels about the toggle - vlayout = QVBoxLayout() - vlayout.setAlignment(Qt.AlignTop) - vlayout.addWidget(labels_widget) - vlayout.addWidget(ToggleWidgetLabel('Information', labels_widget)) + # Widget containing toggle label and container + super(InfoWidget, self).__init__('Information', labels_widget, parent) - self.setLayout(vlayout) - - # self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) + self.setStyleSheet(self.STYLESHEET) def set_document(self, document): """Updates controls to reflect the document. Clears controls if diff --git a/inselect/gui/main_window.py b/inselect/gui/main_window.py index 1c1468b..086ee5d 100644 --- a/inselect/gui/main_window.py +++ b/inselect/gui/main_window.py @@ -6,8 +6,9 @@ from PySide import QtGui from PySide.QtCore import Qt, QEvent, QSettings -from PySide.QtGui import (QAction, QDesktopServices, QHBoxLayout, QMenu, - QMessageBox, QVBoxLayout, QWidget) +from PySide.QtGui import (QAction, QDesktopServices, QMenu, QMessageBox, + QPushButton, QScrollArea, QSizePolicy, QToolBar, + QVBoxLayout, QWidget) # This import is to register our icon resources with QT import inselect.gui.icons # noqa @@ -25,6 +26,7 @@ from .cookie_cutter_widget import CookieCutterWidget from .format_validation_problems import format_validation_problems from .model import Model +from .navigator import NavigatorView from .plugins.barcode import BarcodePlugin from .plugins.segment import SegmentPlugin from .plugins.subsegment import SubsegmentPlugin @@ -43,6 +45,14 @@ class MainWindow(QtGui.QMainWindow): """The application's main window """ + STYLESHEET = """ + ToggleWidgetLabel { + text-decoration: none; + font-weight: bold; + color: black; + } + """ + DOCUMENT_FILE_FILTER = u'Inselect documents (*{0});;Images ({1})'.format( InselectDocument.EXTENSION, u' '.join(IMAGE_PATTERNS) @@ -54,17 +64,108 @@ def __init__(self, app, filename=None): super(MainWindow, self).__init__() self.app = app + self.setStyleSheet(self.STYLESHEET) + + # Plugins + self.plugins = (SegmentPlugin, SubsegmentPlugin, BarcodePlugin) + # QActions. Populated in self.create_menu_actions() + self.plugin_actions = len(self.plugins) * [None] + # QActions. Populated in self.create_menu_actions() + self.plugin_config_ui_actions = len(self.plugins) * [None] + self.plugin_image = None + self.plugin_image_visible = False + + # Colour scheme QActions. Populated in self._create_menu_actions() and + # self._create_non_menu_actions() + self.colour_scheme_actions = [] + + # Model + self.model = Model() + self.model.modified_changed.connect(self.modified_changed) + + self._create_menu_actions() + self._create_non_menu_actions() + self._create_views() + self._create_widgets() + self._create_toolbar() + self._create_menus() + + # Conect signals + self.tabs.currentChanged.connect(self.current_tab_changed) + colour_scheme_choice().colour_scheme_changed.connect( + self.colour_scheme_changed + ) + self.boxes_view.viewport_changed.connect( + self.view_navigator.thumbnail.new_focus_rect + ) + self.view_object.selectionModel().selectionChanged.connect( + self.selection_changed + ) + + # Main window layout + self.setCentralWidget(self.splitter) + + # Document + self.document = None + self.document_path = None + + # Long-running operations are run in their own thread + self.running_operation = None + + # Event filters, for handling drag and drop + self.tabs.installEventFilter(self) + self.boxes_view.installEventFilter(self) + self.view_metadata.installEventFilter(self) + self.view_object.installEventFilter(self) + self.view_summary.widget.installEventFilter(self) + self.view_selector.installEventFilter(self) + self.view_navigator.widget.installEventFilter(self) + self.setAcceptDrops(True) + + self.empty_document() + + if filename: + self.open_file(filename) + + def _create_views(self): + "Creates view objects" # Boxes view self.view_graphics_item = GraphicsItemView() # self.boxes_view is a QGraphicsView, not a QAbstractItemView self.boxes_view = BoxesView(self.view_graphics_item.scene) + # A toolbar containing zoom in and out + self.nav_toolbar = QToolBar("Navigator") + self.nav_toolbar.addAction(self.zoom_in_action) + self.nav_toolbar.addAction(self.zoom_out_action) + self.nav_toolbar.addAction(self.zoom_to_selection_action) + self.nav_toolbar.addAction(self.zoom_home_action) + # Object, metadata and summary views self.view_metadata = MetadataView() self.view_object = ObjectView() self.view_summary = SummaryView() self.view_selector = SelectorView() + self.view_navigator = NavigatorView(nav_toolbar=self.nav_toolbar) + + # Set model + self.view_graphics_item.setModel(self.model) + self.view_metadata.setModel(self.model) + self.view_object.setModel(self.model) + self.view_summary.setModel(self.model) + self.view_selector.setModel(self.model) + self.view_navigator.setModel(self.model) + + # A consistent selection across all views + sm = self.view_object.selectionModel() + self.view_graphics_item.setSelectionModel(sm) + self.view_metadata.setSelectionModel(sm) + self.view_summary.setSelectionModel(sm) + self.view_selector.setSelectionModel(sm) + self.view_navigator.setSelectionModel(sm) + def _create_widgets(self): + "Creates widgets owned by the MainWindow" # Views in tabs self.tabs = QtGui.QTabWidget() self.tabs.addTab(self.boxes_view, 'Boxes') @@ -74,24 +175,29 @@ def __init__(self, app, filename=None): # Information about the loaded document self.info_widget = InfoWidget() - # Cookie cutter widget - self.cookie_cutter_widget = CookieCutterWidget() - self.cookie_cutter_widget.save_to_new_action.triggered.connect( - self.save_to_cookie_cutter - ) - self.cookie_cutter_widget.apply_current_action.triggered.connect( - self.apply_cookie_cutter - ) - cookie_cutter_choice().cookie_cutter_changed.connect( - self.new_cookie_cutter - ) - - # Metadata view above info - sidebar_layout = QVBoxLayout() - sidebar_layout.addWidget(self.view_metadata.widget) - sidebar_layout.addWidget(self.info_widget) - sidebar = QWidget() - sidebar.setLayout(sidebar_layout) + # Sidebar with navigator, metadata and document information + right_bar_layout = QVBoxLayout() + right_bar_layout.addWidget(self.view_navigator.widget) + right_bar_layout.addWidget(self.view_metadata.widget) + right_bar_layout.addWidget(self.info_widget) + right_bar_layout.setSpacing(4) + + # Empty widget with stretch to prevent other widgets from exanding to + # fill + right_bar_layout.addWidget(QWidget(), stretch=1) + right_bar = QWidget() + right_bar.setLayout(right_bar_layout) + + # A scrollable container for the form + right_bar_container = QScrollArea() + right_bar_container.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + right_bar_container.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + right_bar_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + right_bar_container.setWidget(right_bar) + + # Make the controls fill the available horizontal space + # http://qt-project.org/forums/viewthread/11012 + right_bar_container.setWidgetResizable(True) # Summary view below tabs summary_and_tabs_layout = QVBoxLayout() @@ -103,79 +209,12 @@ def __init__(self, app, filename=None): summary_and_tabs = QWidget() summary_and_tabs.setLayout(summary_and_tabs_layout) - # Tabs alongside metadata fields + # Left bar, tabs, right bar self.splitter = QtGui.QSplitter() self.splitter.addWidget(summary_and_tabs) - self.splitter.addWidget(sidebar) + self.splitter.addWidget(right_bar_container) self.splitter.setSizes([600, 300]) - # Main window layout - self.setCentralWidget(self.splitter) - - # Document - self.document = None - self.document_path = None - - # Model - self.model = Model() - self.model.modified_changed.connect(self.modified_changed) - - # Views - self.view_graphics_item.setModel(self.model) - self.view_metadata.setModel(self.model) - self.view_object.setModel(self.model) - self.view_summary.setModel(self.model) - self.view_selector.setModel(self.model) - - # A consistent selection across all views - sm = self.view_object.selectionModel() - self.view_graphics_item.setSelectionModel(sm) - self.view_metadata.setSelectionModel(sm) - self.view_summary.setSelectionModel(sm) - self.view_selector.setSelectionModel(sm) - - # Plugins - self.plugins = (SegmentPlugin, SubsegmentPlugin, BarcodePlugin) - # QActions. Populated in self.create_menu_actions() - self.plugin_actions = len(self.plugins) * [None] - # QActions. Populated in self.create_menu_actions() - self.plugin_config_ui_actions = len(self.plugins) * [None] - self.plugin_image = None - self.plugin_image_visible = False - - # Colour scheme QActions. Populated in self.create_actions() - self.colour_scheme_actions = [] - - # Long-running operations are run in their own thread. - self.running_operation = None - - self.create_menu_actions() - self.create_non_menu_actions() - self.create_toolbar() - self.create_menus() - - # Conect signals - self.tabs.currentChanged.connect(self.current_tab_changed) - sm.selectionChanged.connect(self.selection_changed) - colour_scheme_choice().colour_scheme_changed.connect( - self.colour_scheme_changed - ) - - # Filter events - self.tabs.installEventFilter(self) - self.boxes_view.installEventFilter(self) - self.view_metadata.installEventFilter(self) - self.view_object.installEventFilter(self) - self.view_summary.installEventFilter(self) - self.view_selector.installEventFilter(self) - - self.empty_document() - - self.setAcceptDrops(True) - - if filename: - self.open_file(filename) - def modified_changed(self): "Updated UI's modified state" debug_print('MainWindow.modified_changed') @@ -355,6 +394,8 @@ def open_document(self, path=None, document=None): RecentDocuments().add_path(path) self._sync_recent_documents_actions() + self.zoom_home() + self.sync_ui() if not is_writable(path): @@ -642,18 +683,22 @@ def closeEvent(self, event): @report_to_user def zoom_in(self): self.boxes_view.zoom_in() + self.sync_ui() @report_to_user def zoom_out(self): self.boxes_view.zoom_out() + self.sync_ui() @report_to_user - def toggle_zoom(self): - self.boxes_view.toggle_zoom() + def zoom_to_selection(self): + self.boxes_view.toggle_zoom_to_selection() + self.sync_ui() @report_to_user def zoom_home(self): self.boxes_view.zoom_home() + self.sync_ui() @report_to_user def show_grid(self): @@ -840,7 +885,7 @@ def toggle_plugin_image(self): self.plugin_image_visible = not self.plugin_image_visible self.update_boxes_display_pixmap() - def create_menu_actions(self): + def _create_menu_actions(self): """Creates actions that are associated with menu items """ # File menu @@ -872,7 +917,8 @@ def create_menu_actions(self): ) self.close_action = QAction( "&Close", self, - shortcut=QtGui.QKeySequence.Close, triggered=self.close_document + shortcut=QtGui.QKeySequence.Close, triggered=self.close_document, + icon=self.style().standardIcon(QtGui.QStyle.SP_DialogCloseButton) ) self.exit_action = QAction( "E&xit", self, @@ -1004,13 +1050,13 @@ def create_menu_actions(self): triggered=self.zoom_out, icon=self.style().standardIcon(QtGui.QStyle.SP_ArrowDown) ) - self.toogle_zoom_action = QAction( - "&Toogle Zoom", self, shortcut='Z', triggered=self.toggle_zoom - ) self.zoom_home_action = QAction( - "Fit To Window", self, - shortcut=QtGui.QKeySequence.MoveToStartOfDocument, - triggered=self.zoom_home + "Zoom home", self, shortcut=QtGui.QKeySequence.MoveToStartOfDocument, + triggered=self.zoom_home, checkable=True + ) + self.zoom_to_selection_action = QAction( + "&Zoom to selection", self, shortcut='z', + triggered=self.zoom_to_selection, checkable=True ) # TODO LH Is F3 (normally meaning 'find next') really the right # shortcut for the 'toggle plugin image' action? @@ -1037,7 +1083,7 @@ def create_menu_actions(self): # Help menu self.about_action = QAction("&About", self, triggered=self.about) - def create_non_menu_actions(self): + def _create_non_menu_actions(self): """Creates actions that are not associated with menu items """ # Menu-less actions @@ -1063,23 +1109,46 @@ def create_non_menu_actions(self): self.addAction(self.previous_tab_action) self.addAction(self.next_tab_action) - def create_toolbar(self): + def _create_toolbar(self): """Creates the toolbar """ + # A toolbar containing document open, save etc + # First create some widgets to go onto the toolbar + + # A popup menu of recent documents + self.recent_docs_button = QPushButton("Recent") + self.recent_docs_button.setStyleSheet("text-align: left") + self.recent_docs_popup = QMenu() + for action in self.recent_doc_actions: + self.recent_docs_popup.addAction(action) + self.recent_docs_button.setMenu(self.recent_docs_popup) + + # Cookie cutter widget + self.cookie_cutter_widget = CookieCutterWidget() + self.cookie_cutter_widget.save_to_new_action.triggered.connect( + self.save_to_cookie_cutter + ) + self.cookie_cutter_widget.apply_current_action.triggered.connect( + self.apply_cookie_cutter + ) + cookie_cutter_choice().cookie_cutter_changed.connect( + self.new_cookie_cutter + ) + self.toolbar = self.addToolBar("Edit") self.toolbar.addAction(self.open_action) + self.toolbar.addWidget(self.recent_docs_button) self.toolbar.addAction(self.save_action) + self.toolbar.addAction(self.close_action) + self.toolbar.addSeparator() for action in filter(lambda a: a.icon(), self.plugin_actions): self.toolbar.addAction(action) - self.toolbar.addAction(self.zoom_in_action) - self.toolbar.addAction(self.zoom_out_action) - self.toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) self.toolbar.addWidget(self.cookie_cutter_widget) self.toolbar.addSeparator() self.toolbar.addWidget(self.view_selector.widget) - def create_menus(self): + def _create_menus(self): """Create menu items """ self._file_menu = QMenu("&File", self) @@ -1130,7 +1199,7 @@ def create_menus(self): self._view_menu.addSeparator() self._view_menu.addAction(self.zoom_in_action) self._view_menu.addAction(self.zoom_out_action) - self._view_menu.addAction(self.toogle_zoom_action) + self._view_menu.addAction(self.zoom_to_selection_action) self._view_menu.addAction(self.zoom_home_action) self._view_menu.addAction(self.toggle_plugin_image_action) self._view_menu.addSeparator() @@ -1362,11 +1431,13 @@ def sync_ui(self): # View self.boxes_view_action.setChecked(boxes_view_visible) self.metadata_view_action.setChecked(not boxes_view_visible) - self.zoom_in_action.setEnabled(document and boxes_view_visible) - self.zoom_out_action.setEnabled(document and boxes_view_visible) - self.toogle_zoom_action.setEnabled(document and boxes_view_visible) - self.zoom_home_action.setEnabled(document and boxes_view_visible) - self.toggle_plugin_image_action.setEnabled(document and boxes_view_visible) + self.zoom_in_action.setEnabled(document) + self.zoom_out_action.setEnabled(document) + self.zoom_to_selection_action.setEnabled(document) + self.zoom_home_action.setEnabled(document) + self.zoom_to_selection_action.setChecked(not self.boxes_view.zoomed_out) + self.zoom_home_action.setChecked(self.boxes_view.zoomed_out) + self.toggle_plugin_image_action.setEnabled(document) self.show_object_grid_action.setEnabled(objects_view_visible) self.show_object_expanded_action.setEnabled(objects_view_visible) current_colour_scheme = colour_scheme_choice().current['Name'] diff --git a/inselect/gui/navigator.py b/inselect/gui/navigator.py new file mode 100644 index 0000000..da44fbf --- /dev/null +++ b/inselect/gui/navigator.py @@ -0,0 +1,105 @@ +from PySide import QtGui +from PySide.QtCore import QModelIndex, Qt, QRect, QRectF +from PySide.QtGui import (QAbstractItemView, QGroupBox, QPainter, QPen, + QSizePolicy, QVBoxLayout, QWidget) + +from .popup_panel import PopupPanel +from .roles import PixmapRole + + +class NavigatorView(QAbstractItemView): + """View that provides a thumbnail navigator image of the document + """ + SIZE = 200 + + def __init__(self, parent=None, nav_toolbar=None): + # This view is not visible + super(NavigatorView, self).__init__(None) + + self.thumbnail = ThumbnailWidget() + self.thumbnail.setFixedWidth(self.SIZE) + self.thumbnail.setFixedHeight(self.SIZE) + self.thumbnail.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.thumbnail.setContentsMargins( + 0, # left + 0, # top + 0, # right + 0 # bottom + ) + + # Widget containing nav toolbar and thumbnail + layout = QVBoxLayout() + layout.addWidget(nav_toolbar) + layout.addWidget(self.thumbnail) + layout.setAlignment(self.thumbnail, Qt.AlignHCenter) + layout.setSpacing(0) + layout.setContentsMargins( + 0, # left + 0, # top + 0, # right + 0 # bottom + ) + container = QWidget() + container.setLayout(layout) + + # Widget containing toggle label and container + self.widget = PopupPanel('Navigator', container, parent) + + def reset(self): + """QAbstractItemView virtual + """ + self.thumbnail.set_pixmap(self.model().data(QModelIndex(), PixmapRole)) + + +class ThumbnailWidget(QWidget): + def __init__(self, parent=None): + super(ThumbnailWidget, self).__init__(parent) + self.pixmap = None + self.focus = None + + def set_pixmap(self, pixmap): + self.pixmap = pixmap + self.update() + + def new_focus_rect(self, focus): + """focus - a QRectF in normalised (i.e., between 0 and 1) coordinates + """ + self.focus = focus + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + if self.pixmap: + size = self.pixmap.size() + aspect = float(size.width()) / size.height() + if aspect > 1: + # Image is wider than it is tall - centre vertically + left = 0 + width = self.width() + height = self.height() / aspect + top = (self.height() - height) / 2 + else: + # Image is taller than it is wide - centre horizontally + top = 0 + height = self.height() + width = self.width() * aspect + left = (self.width() - width) / 2 + + painter.drawPixmap(QRect(left, top, width, height), self.pixmap) + + if self.focus: + # self.focus contains coords between 0 and 1 - translate these + # to pixels + pixels = QRectF(left + self.focus.left() * width, + top + self.focus.top() * height, + self.focus.width() * width, + self.focus.height() * height) + # Outer box in white + painter.setPen(QPen(Qt.white, 1, Qt.SolidLine)) + painter.drawRect(pixels) + # Inner box in black + painter.setPen(QPen(Qt.black, 1, Qt.SolidLine)) + painter.drawRect(pixels.adjusted(1, 1, -1, -1)) + else: + painter.setBrush(QtGui.qApp.palette().brush(self.backgroundRole())) + painter.drawRect(self.rect()) diff --git a/inselect/gui/popup_panel.py b/inselect/gui/popup_panel.py new file mode 100644 index 0000000..34e8cfc --- /dev/null +++ b/inselect/gui/popup_panel.py @@ -0,0 +1,17 @@ +from PySide.QtCore import Qt +from PySide.QtGui import QGroupBox, QVBoxLayout + +from .toggle_widget_label import ToggleWidgetLabel + + +class PopupPanel(QGroupBox): + """A ToggleWidgetLabel and a widget contained within a QGroupBox + """ + def __init__(self, label, widget, parent=None, flags=0): + super(PopupPanel, self).__init__(parent, flags) + + layout = QVBoxLayout() + layout.setAlignment(Qt.AlignTop) + layout.addWidget(ToggleWidgetLabel(label, widget)) + layout.addWidget(widget) + self.setLayout(layout) diff --git a/inselect/gui/utils.py b/inselect/gui/utils.py index 04bcb07..1b2fd71 100644 --- a/inselect/gui/utils.py +++ b/inselect/gui/utils.py @@ -66,7 +66,7 @@ def painter_state(painter): def report_to_user(f): - """Decorator that reports exceptions to the user in a model QDialog + """Decorator that reports exceptions to the user in a modal QDialog """ @wraps(f) def wrapper(self, *args, **kwargs): diff --git a/inselect/gui/views/boxes/boxes_view.py b/inselect/gui/views/boxes/boxes_view.py index 22735d0..6f388cf 100644 --- a/inselect/gui/views/boxes/boxes_view.py +++ b/inselect/gui/views/boxes/boxes_view.py @@ -1,5 +1,5 @@ from PySide import QtGui -from PySide.QtCore import Qt, QRectF, QSizeF +from PySide.QtCore import Qt, QRectF, QSizeF, Signal from inselect.lib.utils import debug_print from inselect.gui.utils import unite_rects @@ -7,25 +7,31 @@ class BoxesView(QtGui.QGraphicsView): - """ + """Zoomable image with bounding boxes """ MAXIMUM_ZOOM = 3 # User can't zoom in more than 1:3 + viewport_changed = Signal(QRectF) + def __init__(self, scene, parent=None): super(BoxesView, self).__init__(scene, parent) self.setCursor(Qt.CrossCursor) self.setDragMode(QtGui.QGraphicsView.RubberBandDrag) - # If True, resizeEvent() will cause the scale to be updated to fit the - # scene within the view - self.fit_to_view = True + # If True, resizeEvent() will cause the scale to be updated to + # fit the scene within the view. + # If False, changes in selection cause the view to scale to encompass + # the current selection. + self.zoomed_out = True # Will contain a temporary Rect object while the user drag-drop-creates # a box self._pending_box = None colour_scheme_choice().colour_scheme_changed.connect(self.colour_scheme_changed) + self.verticalScrollBar().valueChanged.connect(self.scrolled) + self.horizontalScrollBar().valueChanged.connect(self.scrolled) def colour_scheme_changed(self): """Slot for colour_scheme_changed signal @@ -48,11 +54,13 @@ def resizeEvent(self, event): # Check for change in size because many user-interface actions trigger # resizeEvent(), even though they do not cause a change in the view's # size - if self.fit_to_view and event.oldSize() != event.size(): + if self.zoomed_out and event.oldSize() != event.size(): self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio) super(BoxesView, self).resizeEvent(event) + self.viewport_changed.emit(self.normalised_scene_rect()) + def mousePressEvent(self, event): """QGraphicsView virtual """ @@ -159,7 +167,7 @@ def zoom_home(self): """ debug_print('BoxesView.zoom_home') self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio) - self.fit_to_view = True + self.zoomed_out = True @property def absolute_zoom(self): @@ -168,18 +176,21 @@ def absolute_zoom(self): return self.transform().m11() def new_relative_zoom(self, factor): - """Sets a new relative zoom + """Sets a new relative zoom and emits viewport_changed. """ self.new_absolute_zoom(self.absolute_zoom * factor) + self.viewport_changed.emit(self.normalised_scene_rect()) def zoom_to_items(self, items): """Centres view on the centre of the items and, if view is set to 'fit to view', sets the zoom level to encompass items. + Emits viewport_changed. """ united = unite_rects(i.sceneBoundingRect() for i in items) - if self.fit_to_view: + if self.zoomed_out: debug_print('Ensuring [{0}] items visible'.format(len(items))) self.ensureVisible(united) + self.viewport_changed.emit(self.normalised_scene_rect()) else: # Some space # TODO LH Space should be in visible units @@ -190,22 +201,25 @@ def zoom_to_items(self, items): # TODO LH Need a better solution if self.absolute_zoom > self.MAXIMUM_ZOOM: self.new_absolute_zoom(self.MAXIMUM_ZOOM) + else: + self.viewport_changed.emit(self.normalised_scene_rect()) - def toggle_zoom(self): + def toggle_zoom_to_selection(self): """Toggles between 'fit to screen' and a mild zoom / zoom to selected """ - self.fit_to_view = not self.fit_to_view - if self.fit_to_view: + self.zoomed_out = not self.zoomed_out + if self.zoomed_out: self.zoom_home() else: selected = self.scene().selectedItems() if selected: self.zoom_to_items(selected) else: + # There is no curent selection - apply a mild zoom self.new_relative_zoom(4.0) def new_absolute_zoom(self, factor): - """Sets a new absolute zoom + """Sets a new absolute zoom and emits viewport_changed. """ f = factor scene_rect = self.scene().sceneRect() # Scene @@ -235,7 +249,7 @@ def new_absolute_zoom(self, factor): mouse_pos = None self.setTransform(QtGui.QTransform.fromScale(f, f)) - self.fit_to_view = False + self.zoomed_out = False if selected: # Centre on selected items @@ -249,6 +263,28 @@ def new_absolute_zoom(self, factor): # Default behaviour is fine pass + self.viewport_changed.emit(self.normalised_scene_rect()) + + def scrolled(self): + """Slot for scroll bars' valueChanged signals + """ + self.viewport_changed.emit(self.normalised_scene_rect()) + + def normalised_scene_rect(self): + """QRectF with values between 0 and 1 indicating the current viewport + """ + if self.scene().is_empty: + return QRectF(0, 0, 1, 1) + else: + visible = self.mapToScene(self.viewport().rect()).boundingRect() + scene_rect = self.scene().sceneRect() + return QRectF( + visible.x() / scene_rect.width(), + visible.y() / scene_rect.height(), + visible.width() / scene_rect.width(), + visible.height() / scene_rect.height() + ) + def dragEnterEvent(self, event): """QWidget virtual """ diff --git a/inselect/gui/views/metadata.py b/inselect/gui/views/metadata.py index 2b5b2ea..6de8a01 100644 --- a/inselect/gui/views/metadata.py +++ b/inselect/gui/views/metadata.py @@ -11,10 +11,11 @@ from inselect.lib.utils import debug_print from inselect.gui.colours import colour_scheme_choice +from inselect.gui.popup_panel import PopupPanel from inselect.gui.roles import MetadataRole -from inselect.gui.user_template_choice import user_template_choice -from inselect.gui.utils import relayout_widget from inselect.gui.toggle_widget_label import ToggleWidgetLabel +from inselect.gui.utils import relayout_widget +from inselect.gui.user_template_choice import user_template_choice from inselect.gui.user_template_popup_button import UserTemplatePopupButton @@ -37,28 +38,19 @@ def __init__(self, parent=None): # A container for the controls self._form_container = FormContainer() - - # A scrollable container for the form - self._form_scroll = QScrollArea(parent) - self._form_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self._form_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self._form_scroll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self._form_scroll.setWidget(self._form_container) - - # Make the controls fill the available horizontal space - # http://qt-project.org/forums/viewthread/11012 - self._form_scroll.setWidgetResizable(True) - self._create_controls() - # List of templates is fixed at the top - form can be scrolled + # Popup buttom above controls layout = QVBoxLayout() layout.addWidget(self.popup_button) - layout.addWidget(self._form_scroll) + layout.addWidget(self._form_container) + + # Container for the popup and form + container = QWidget() + container.setLayout(layout) - # Top-level container for the list of templates and form - self.widget = QWidget(parent) - self.widget.setLayout(layout) + # Widget containing toggle label and container + self.widget = PopupPanel('Metadata', container, parent) def refresh_user_template(self): "Refreshes the UI with the currently selected UserTemplate" @@ -158,9 +150,9 @@ def controls_from_template(self, template): def _new_group(self): """Returns a new layout, used during controls creation """ - l = QFormLayout() - l.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) - return l + layout = QFormLayout() + layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) + return layout def _close_group(self, main_layout, group_name, group_layout): """Closes the the existing group, used during controls creation @@ -181,6 +173,12 @@ def _close_group(self, main_layout, group_name, group_layout): group_box_layout.addWidget(ToggleWidgetLabel(group_name, controls_widget)) group_box_layout.addWidget(controls_widget) + group_box_layout.setContentsMargins( + 0, # left + 0, # top + 0, # right + 0 # bottom + ) group_box = QGroupBox() group_box.setLayout(group_box_layout) @@ -197,6 +195,12 @@ def _create_field_controls(self, template): # Show controls stacked vertically main_layout = QFormLayout() main_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) + main_layout.setContentsMargins( + 0, # left + 0, # top + 0, # right + 0 # bottom + ) # Mapping { control, field name } controls = {} From 4eb138cc46321885453a6c2866256241dff1fe6f Mon Sep 17 00:00:00 2001 From: Lawrence Hudson Date: Tue, 26 Apr 2016 11:32:42 +0100 Subject: [PATCH 2/3] Tweaks to interface --- inselect/gui/main_window.py | 29 ++++++++----- inselect/gui/views/boxes/boxes_view.py | 59 +++++++++++++++----------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/inselect/gui/main_window.py b/inselect/gui/main_window.py index 086ee5d..1e6a608 100644 --- a/inselect/gui/main_window.py +++ b/inselect/gui/main_window.py @@ -98,6 +98,13 @@ def __init__(self, app, filename=None): self.boxes_view.viewport_changed.connect( self.view_navigator.thumbnail.new_focus_rect ) + # TODO LH Syncing the UI everytime the boxes view's viewport changes + # is inefficient. We only need to set the checked states of + # self.zoom_to_selection_action and + # self.zoom_home_action.setChecked as the viewport changes. + self.boxes_view.viewport_changed.connect( + self.sync_ui + ) self.view_object.selectionModel().selectionChanged.connect( self.selection_changed ) @@ -138,8 +145,8 @@ def _create_views(self): self.nav_toolbar = QToolBar("Navigator") self.nav_toolbar.addAction(self.zoom_in_action) self.nav_toolbar.addAction(self.zoom_out_action) - self.nav_toolbar.addAction(self.zoom_to_selection_action) self.nav_toolbar.addAction(self.zoom_home_action) + self.nav_toolbar.addAction(self.zoom_to_selection_action) # Object, metadata and summary views self.view_metadata = MetadataView() @@ -683,22 +690,18 @@ def closeEvent(self, event): @report_to_user def zoom_in(self): self.boxes_view.zoom_in() - self.sync_ui() @report_to_user def zoom_out(self): self.boxes_view.zoom_out() - self.sync_ui() @report_to_user def zoom_to_selection(self): self.boxes_view.toggle_zoom_to_selection() - self.sync_ui() @report_to_user def zoom_home(self): self.boxes_view.zoom_home() - self.sync_ui() @report_to_user def show_grid(self): @@ -1051,11 +1054,12 @@ def _create_menu_actions(self): icon=self.style().standardIcon(QtGui.QStyle.SP_ArrowDown) ) self.zoom_home_action = QAction( - "Zoom home", self, shortcut=QtGui.QKeySequence.MoveToStartOfDocument, + "Whole image", self, + shortcut=QtGui.QKeySequence.MoveToStartOfDocument, triggered=self.zoom_home, checkable=True ) self.zoom_to_selection_action = QAction( - "&Zoom to selection", self, shortcut='z', + "&Selected", self, shortcut='z', triggered=self.zoom_to_selection, checkable=True ) # TODO LH Is F3 (normally meaning 'find next') really the right @@ -1199,8 +1203,8 @@ def _create_menus(self): self._view_menu.addSeparator() self._view_menu.addAction(self.zoom_in_action) self._view_menu.addAction(self.zoom_out_action) - self._view_menu.addAction(self.zoom_to_selection_action) self._view_menu.addAction(self.zoom_home_action) + self._view_menu.addAction(self.zoom_to_selection_action) self._view_menu.addAction(self.toggle_plugin_image_action) self._view_menu.addSeparator() self._view_menu.addAction(self.show_object_grid_action) @@ -1434,9 +1438,12 @@ def sync_ui(self): self.zoom_in_action.setEnabled(document) self.zoom_out_action.setEnabled(document) self.zoom_to_selection_action.setEnabled(document) - self.zoom_home_action.setEnabled(document) - self.zoom_to_selection_action.setChecked(not self.boxes_view.zoomed_out) - self.zoom_home_action.setChecked(self.boxes_view.zoomed_out) + self.zoom_to_selection_action.setChecked( + document and 'follow_selection' == self.boxes_view.zoom_mode + ) + self.zoom_home_action.setChecked( + document and 'whole_scene' == self.boxes_view.zoom_mode + ) self.toggle_plugin_image_action.setEnabled(document) self.show_object_grid_action.setEnabled(objects_view_visible) self.show_object_expanded_action.setEnabled(objects_view_visible) diff --git a/inselect/gui/views/boxes/boxes_view.py b/inselect/gui/views/boxes/boxes_view.py index 6f388cf..1bcbeaf 100644 --- a/inselect/gui/views/boxes/boxes_view.py +++ b/inselect/gui/views/boxes/boxes_view.py @@ -19,11 +19,12 @@ def __init__(self, scene, parent=None): self.setCursor(Qt.CrossCursor) self.setDragMode(QtGui.QGraphicsView.RubberBandDrag) - # If True, resizeEvent() will cause the scale to be updated to + # If 'whole_scene', resizeEvent() will cause the scale to be updated to # fit the scene within the view. - # If False, changes in selection cause the view to scale to encompass - # the current selection. - self.zoomed_out = True + # If 'follow_selection', changes in selection cause the view to scale to + # encompass the current selection. + # If 'fixed', changes in selection and size do not alter the viewport. + self.zoom_mode = 'whole_scene' # Will contain a temporary Rect object while the user drag-drop-creates # a box @@ -54,7 +55,7 @@ def resizeEvent(self, event): # Check for change in size because many user-interface actions trigger # resizeEvent(), even though they do not cause a change in the view's # size - if self.zoomed_out and event.oldSize() != event.size(): + if 'whole_scene' == self.zoom_mode and event.oldSize() != event.size(): self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio) super(BoxesView, self).resizeEvent(event) @@ -163,11 +164,11 @@ def zoom_out(self): self.new_relative_zoom(0.9) def zoom_home(self): - """Zoom to show the entire scene + """Zooms to show the entire scene and sets zoom_mode to 'whole_scene' """ debug_print('BoxesView.zoom_home') + self.zoom_mode = 'whole_scene' self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio) - self.zoomed_out = True @property def absolute_zoom(self): @@ -176,10 +177,12 @@ def absolute_zoom(self): return self.transform().m11() def new_relative_zoom(self, factor): - """Sets a new relative zoom and emits viewport_changed. + """Sets a new relative zoom, sets zoom_mode to 'fixed' and emits + viewport_changed. """ + # Do not override the follow selection + self.zoom_mode = 'fixed' self.new_absolute_zoom(self.absolute_zoom * factor) - self.viewport_changed.emit(self.normalised_scene_rect()) def zoom_to_items(self, items): """Centres view on the centre of the items and, if view is set to @@ -187,35 +190,44 @@ def zoom_to_items(self, items): Emits viewport_changed. """ united = unite_rects(i.sceneBoundingRect() for i in items) - if self.zoomed_out: + if 'whole_scene' == self.zoom_mode: debug_print('Ensuring [{0}] items visible'.format(len(items))) self.ensureVisible(united) self.viewport_changed.emit(self.normalised_scene_rect()) else: - # Some space - # TODO LH Space should be in visible units - debug_print('Zooming on [{0}] items'.format(len(items))) - united.adjust(-20, -20, 40, 40) - self.fitInView(united, Qt.KeepAspectRatio) - - # TODO LH Need a better solution - if self.absolute_zoom > self.MAXIMUM_ZOOM: - self.new_absolute_zoom(self.MAXIMUM_ZOOM) + debug_print('Showing [{0}] items'.format(len(items))) + # Add some padding around the selection + padding = 20 + if 'follow_selection' == self.zoom_mode: + # Update zoom + united.adjust(-padding, -padding, 2 * padding, 2 * padding) + self.fitInView(united, Qt.KeepAspectRatio) + + if self.absolute_zoom > self.MAXIMUM_ZOOM: + # new_absolute_zoom() emits viewport_changed + self.new_absolute_zoom(self.MAXIMUM_ZOOM) + else: + self.viewport_changed.emit(self.normalised_scene_rect()) else: - self.viewport_changed.emit(self.normalised_scene_rect()) + # zoom_mode == fixed + self.ensureVisible(united, xmargin=padding, ymargin=padding) def toggle_zoom_to_selection(self): - """Toggles between 'fit to screen' and a mild zoom / zoom to selected + """Toggles between 'whole_scene' and a either 'fixed' with a mild zoom + (if no boxes are selected) or 'follow_selection' (if one or more boxes + are selected). """ - self.zoomed_out = not self.zoomed_out - if self.zoomed_out: + if 'whole_scene' != self.zoom_mode: self.zoom_home() else: + # Currently showing the whole image selected = self.scene().selectedItems() if selected: + self.zoom_mode = 'follow_selection' self.zoom_to_items(selected) else: # There is no curent selection - apply a mild zoom + self.zoom_mode = 'fixed' self.new_relative_zoom(4.0) def new_absolute_zoom(self, factor): @@ -249,7 +261,6 @@ def new_absolute_zoom(self, factor): mouse_pos = None self.setTransform(QtGui.QTransform.fromScale(f, f)) - self.zoomed_out = False if selected: # Centre on selected items From 78e31d95a51d011fe0e33de073aa3b025950a7b3 Mon Sep 17 00:00:00 2001 From: Lawrence Hudson Date: Tue, 26 Apr 2016 12:03:17 +0100 Subject: [PATCH 3/3] Update tests for new behaviour --- inselect/gui/main_window.py | 7 ++++--- inselect/tests/gui/test_action_state.py | 4 ++-- inselect/tests/gui/test_boxes_view.py | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/inselect/gui/main_window.py b/inselect/gui/main_window.py index 1e6a608..4e0af48 100644 --- a/inselect/gui/main_window.py +++ b/inselect/gui/main_window.py @@ -1437,12 +1437,13 @@ def sync_ui(self): self.metadata_view_action.setChecked(not boxes_view_visible) self.zoom_in_action.setEnabled(document) self.zoom_out_action.setEnabled(document) - self.zoom_to_selection_action.setEnabled(document) + self.zoom_home_action.setEnabled(document) self.zoom_to_selection_action.setChecked( - document and 'follow_selection' == self.boxes_view.zoom_mode + 'follow_selection' == self.boxes_view.zoom_mode ) + self.zoom_to_selection_action.setEnabled(document) self.zoom_home_action.setChecked( - document and 'whole_scene' == self.boxes_view.zoom_mode + 'whole_scene' == self.boxes_view.zoom_mode ) self.toggle_plugin_image_action.setEnabled(document) self.show_object_grid_action.setEnabled(objects_view_visible) diff --git a/inselect/tests/gui/test_action_state.py b/inselect/tests/gui/test_action_state.py index 46eb240..afa01fc 100644 --- a/inselect/tests/gui/test_action_state.py +++ b/inselect/tests/gui/test_action_state.py @@ -36,7 +36,7 @@ def _test_no_document(self): self.assertTrue(w.metadata_view_action.isEnabled()) self.assertFalse(w.zoom_in_action.isEnabled()) self.assertFalse(w.zoom_out_action.isEnabled()) - self.assertFalse(w.toogle_zoom_action.isEnabled()) + self.assertFalse(w.zoom_to_selection_action.isEnabled()) self.assertFalse(w.zoom_home_action.isEnabled()) def _test_document_open(self): @@ -64,7 +64,7 @@ def _test_document_open(self): self.assertTrue(w.metadata_view_action.isEnabled()) self.assertTrue(w.zoom_in_action.isEnabled()) self.assertTrue(w.zoom_out_action.isEnabled()) - self.assertTrue(w.toogle_zoom_action.isEnabled()) + self.assertTrue(w.zoom_to_selection_action.isEnabled()) self.assertTrue(w.zoom_home_action.isEnabled()) def test_open_and_closed(self): diff --git a/inselect/tests/gui/test_boxes_view.py b/inselect/tests/gui/test_boxes_view.py index 0bfd028..9b454bf 100644 --- a/inselect/tests/gui/test_boxes_view.py +++ b/inselect/tests/gui/test_boxes_view.py @@ -76,7 +76,7 @@ def test_zoom_home(self): final = w.boxes_view.absolute_zoom self.assertEqual(initial, final) - def test_toggle_zoom(self): + def test_zoom_to_selection(self): "Toggles between zooming in on the selected box and zooming out" w = self.window @@ -88,10 +88,11 @@ def test_toggle_zoom(self): w.select_next_prev(next=True) # Zoom in on the selected item - w.toggle_zoom() + w.zoom_to_selection() + self.assertGreater(w.boxes_view.absolute_zoom, initial) # Zoom all the way out - w.toggle_zoom() + w.zoom_home() final = w.boxes_view.absolute_zoom self.assertEqual(initial, final)