class ThumbnailScrollBar(QFrame): """ A widget that manages the display of the FigureThumbnails that are created when a figure is sent to the IPython console by the kernel and that controls what is displayed in the FigureViewer. """ redirect_stdio = Signal(bool) def __init__(self, figure_viewer, parent=None, background_color=None): super(ThumbnailScrollBar, self).__init__(parent) self._thumbnails = [] self.background_color = background_color self.current_thumbnail = None self.set_figureviewer(figure_viewer) self.setup_gui() def setup_gui(self): """Setup the main layout of the widget.""" scrollarea = self.setup_scrollarea() up_btn, down_btn = self.setup_arrow_buttons() self.setFixedWidth(150) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(up_btn) layout.addWidget(scrollarea) layout.addWidget(down_btn) def setup_scrollarea(self): """Setup the scrollarea that will contain the FigureThumbnails.""" self.view = QWidget() self.scene = QGridLayout(self.view) self.scene.setColumnStretch(0, 100) self.scene.setColumnStretch(2, 100) self.scrollarea = QScrollArea() self.scrollarea.setWidget(self.view) self.scrollarea.setWidgetResizable(True) self.scrollarea.setFrameStyle(0) self.scrollarea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scrollarea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scrollarea.setSizePolicy(QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)) # Set the vertical scrollbar explicitely : # This is required to avoid a "RuntimeError: no access to protected # functions or signals for objects not created from Python" in Linux. self.scrollarea.setVerticalScrollBar(QScrollBar()) return self.scrollarea def setup_arrow_buttons(self): """ Setup the up and down arrow buttons that are placed at the top and bottom of the scrollarea. """ # Get the height of the up/down arrow of the default vertical # scrollbar : vsb = self.scrollarea.verticalScrollBar() style = vsb.style() opt = QStyleOptionSlider() vsb.initStyleOption(opt) vsb_up_arrow = style.subControlRect( QStyle.CC_ScrollBar, opt, QStyle.SC_ScrollBarAddLine, self) # Setup the up and down arrow button : up_btn = up_btn = QPushButton(icon=ima.icon('last_edit_location')) up_btn.setFlat(True) up_btn.setFixedHeight(vsb_up_arrow.size().height()) up_btn.clicked.connect(self.go_up) down_btn = QPushButton(icon=ima.icon('folding.arrow_down_on')) down_btn.setFlat(True) down_btn.setFixedHeight(vsb_up_arrow.size().height()) down_btn.clicked.connect(self.go_down) return up_btn, down_btn def set_figureviewer(self, figure_viewer): """Set the bamespace for the FigureViewer.""" self.figure_viewer = figure_viewer # ---- Save Figure def save_all_figures_as(self): """Save all the figures to a file.""" self.redirect_stdio.emit(False) dirname = getexistingdirectory(self, caption='Save all figures', basedir=getcwd_or_home()) self.redirect_stdio.emit(True) if dirname: return self.save_all_figures_todir(dirname) def save_all_figures_todir(self, dirname): """Save all figure in dirname.""" fignames = [] for thumbnail in self._thumbnails: fig = thumbnail.canvas.fig fmt = thumbnail.canvas.fmt fext = {'image/png': '.png', 'image/jpeg': '.jpg', 'image/svg+xml': '.svg'}[fmt] figname = get_unique_figname(dirname, 'Figure', fext) save_figure_tofile(fig, fmt, figname) fignames.append(figname) return fignames def save_current_figure_as(self): """Save the currently selected figure.""" if self.current_thumbnail is not None: self.save_figure_as(self.current_thumbnail.canvas.fig, self.current_thumbnail.canvas.fmt) def save_figure_as(self, fig, fmt): """Save the figure to a file.""" fext, ffilt = { 'image/png': ('.png', 'PNG (*.png)'), 'image/jpeg': ('.jpg', 'JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)'), 'image/svg+xml': ('.svg', 'SVG (*.svg);;PNG (*.png)')}[fmt] figname = get_unique_figname(getcwd_or_home(), 'Figure', fext) self.redirect_stdio.emit(False) fname, fext = getsavefilename( parent=self.parent(), caption='Save Figure', basedir=figname, filters=ffilt, selectedfilter='', options=None) self.redirect_stdio.emit(True) if fname: save_figure_tofile(fig, fmt, fname) # ---- Thumbails Handlers def add_thumbnail(self, fig, fmt): thumbnail = FigureThumbnail(background_color=self.background_color) thumbnail.canvas.load_figure(fig, fmt) # Scale the thumbnail size, while respecting the figure # dimension ratio. fwidth = thumbnail.canvas.fwidth fheight = thumbnail.canvas.fheight max_length = 100 if fwidth/fheight > 1: canvas_width = max_length canvas_height = canvas_width / fwidth * fheight else: canvas_height = max_length canvas_width = canvas_height / fheight * fwidth thumbnail.canvas.setFixedSize(canvas_width, canvas_height) thumbnail.sig_canvas_clicked.connect(self.set_current_thumbnail) thumbnail.sig_remove_figure.connect(self.remove_thumbnail) thumbnail.sig_save_figure.connect(self.save_figure_as) self._thumbnails.append(thumbnail) self.scene.setRowStretch(self.scene.rowCount()-1, 0) self.scene.addWidget(thumbnail, self.scene.rowCount()-1, 1) self.scene.setRowStretch(self.scene.rowCount(), 100) self.set_current_thumbnail(thumbnail) def remove_current_thumbnail(self): """Remove the currently selected thumbnail.""" if self.current_thumbnail is not None: self.remove_thumbnail(self.current_thumbnail) def remove_all_thumbnails(self): """Remove all thumbnails.""" for thumbnail in self._thumbnails: self.layout().removeWidget(thumbnail) thumbnail.sig_canvas_clicked.disconnect() thumbnail.sig_remove_figure.disconnect() thumbnail.sig_save_figure.disconnect() thumbnail.deleteLater() self._thumbnails = [] self.current_thumbnail = None self.figure_viewer.figcanvas.clear_canvas() def remove_thumbnail(self, thumbnail): """Remove thumbnail.""" if thumbnail in self._thumbnails: index = self._thumbnails.index(thumbnail) self._thumbnails.remove(thumbnail) self.layout().removeWidget(thumbnail) thumbnail.deleteLater() thumbnail.sig_canvas_clicked.disconnect() thumbnail.sig_remove_figure.disconnect() thumbnail.sig_save_figure.disconnect() # Select a new thumbnail if any : if thumbnail == self.current_thumbnail: if len(self._thumbnails) > 0: self.set_current_index(min(index, len(self._thumbnails)-1)) else: self.current_thumbnail = None self.figure_viewer.figcanvas.clear_canvas() def set_current_index(self, index): """Set the currently selected thumbnail by its index.""" self.set_current_thumbnail(self._thumbnails[index]) def get_current_index(self): """Return the index of the currently selected thumbnail.""" try: return self._thumbnails.index(self.current_thumbnail) except ValueError: return -1 def set_current_thumbnail(self, thumbnail): """Set the currently selected thumbnail.""" self.current_thumbnail = thumbnail self.figure_viewer.load_figure( thumbnail.canvas.fig, thumbnail.canvas.fmt) for thumbnail in self._thumbnails: thumbnail.highlight_canvas(thumbnail == self.current_thumbnail) def go_previous_thumbnail(self): """Select the thumbnail previous to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) - 1 index = index if index >= 0 else len(self._thumbnails) - 1 self.set_current_index(index) self.scroll_to_item(index) def go_next_thumbnail(self): """Select thumbnail next to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) + 1 index = 0 if index >= len(self._thumbnails) else index self.set_current_index(index) self.scroll_to_item(index) def scroll_to_item(self, index): """Scroll to the selected item of ThumbnailScrollBar.""" spacing_between_items = self.scene.verticalSpacing() height_view = self.scrollarea.viewport().height() height_item = self.scene.itemAt(index).sizeHint().height() height_view_excluding_item = max(0, height_view - height_item) height_of_top_items = spacing_between_items for i in range(index): item = self.scene.itemAt(i) height_of_top_items += item.sizeHint().height() height_of_top_items += spacing_between_items pos_scroll = height_of_top_items - height_view_excluding_item // 2 vsb = self.scrollarea.verticalScrollBar() vsb.setValue(pos_scroll) # ---- ScrollBar Handlers def go_up(self): """Scroll the scrollbar of the scrollarea up by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() - vsb.singleStep())) def go_down(self): """Scroll the scrollbar of the scrollarea down by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() + vsb.singleStep()))
class ThumbnailScrollBar(QFrame): """ A widget that manages the display of the FigureThumbnails that are created when a figure is sent to the IPython console by the kernel and that controls what is displayed in the FigureViewer. """ redirect_stdio = Signal(bool) _min_scrollbar_width = 100 def __init__(self, figure_viewer, parent=None, background_color=None): super(ThumbnailScrollBar, self).__init__(parent) self._thumbnails = [] self.background_color = background_color self.current_thumbnail = None self.set_figureviewer(figure_viewer) self.setup_gui() def setup_gui(self): """Setup the main layout of the widget.""" scrollarea = self.setup_scrollarea() up_btn, down_btn = self.setup_arrow_buttons() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(up_btn) layout.addWidget(scrollarea) layout.addWidget(down_btn) def setup_scrollarea(self): """Setup the scrollarea that will contain the FigureThumbnails.""" self.view = QWidget() self.scene = QGridLayout(self.view) self.scene.setContentsMargins(0, 0, 0, 0) self.scrollarea = QScrollArea() self.scrollarea.setWidget(self.view) self.scrollarea.setWidgetResizable(True) self.scrollarea.setFrameStyle(0) self.scrollarea.setViewportMargins(2, 2, 2, 2) self.scrollarea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scrollarea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scrollarea.setMinimumWidth(self._min_scrollbar_width) # Set the vertical scrollbar explicitely. # This is required to avoid a "RuntimeError: no access to protected # functions or signals for objects not created from Python" in Linux. self.scrollarea.setVerticalScrollBar(QScrollBar()) # Install an event filter on the scrollbar. self.scrollarea.installEventFilter(self) return self.scrollarea def setup_arrow_buttons(self): """ Setup the up and down arrow buttons that are placed at the top and bottom of the scrollarea. """ # Get the size hint height of the horizontal scrollbar. height = self.scrollarea.horizontalScrollBar().sizeHint().height() # Setup the up and down arrow button. up_btn = up_btn = QPushButton(icon=ima.icon('last_edit_location')) up_btn.setFlat(True) up_btn.setFixedHeight(height) up_btn.clicked.connect(self.go_up) down_btn = QPushButton(icon=ima.icon('folding.arrow_down_on')) down_btn.setFlat(True) down_btn.setFixedHeight(height) down_btn.clicked.connect(self.go_down) return up_btn, down_btn def set_figureviewer(self, figure_viewer): """Set the bamespace for the FigureViewer.""" self.figure_viewer = figure_viewer def eventFilter(self, widget, event): """ An event filter to trigger an update of the thumbnails size so that their width fit that of the scrollarea. """ if event.type() == QEvent.Resize: self._update_thumbnail_size() return super(ThumbnailScrollBar, self).eventFilter(widget, event) # ---- Save Figure def save_all_figures_as(self): """Save all the figures to a file.""" self.redirect_stdio.emit(False) dirname = getexistingdirectory(self, caption='Save all figures', basedir=getcwd_or_home()) self.redirect_stdio.emit(True) if dirname: return self.save_all_figures_todir(dirname) def save_all_figures_todir(self, dirname): """Save all figure in dirname.""" fignames = [] for thumbnail in self._thumbnails: fig = thumbnail.canvas.fig fmt = thumbnail.canvas.fmt fext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/svg+xml': '.svg' }[fmt] figname = get_unique_figname(dirname, 'Figure', fext) save_figure_tofile(fig, fmt, figname) fignames.append(figname) return fignames def save_current_figure_as(self): """Save the currently selected figure.""" if self.current_thumbnail is not None: self.save_figure_as(self.current_thumbnail.canvas.fig, self.current_thumbnail.canvas.fmt) def save_figure_as(self, fig, fmt): """Save the figure to a file.""" fext, ffilt = { 'image/png': ('.png', 'PNG (*.png)'), 'image/jpeg': ('.jpg', 'JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)'), 'image/svg+xml': ('.svg', 'SVG (*.svg);;PNG (*.png)') }[fmt] figname = get_unique_figname(getcwd_or_home(), 'Figure', fext) self.redirect_stdio.emit(False) fname, fext = getsavefilename(parent=self.parent(), caption='Save Figure', basedir=figname, filters=ffilt, selectedfilter='', options=None) self.redirect_stdio.emit(True) if fname: save_figure_tofile(fig, fmt, fname) # ---- Thumbails Handlers def _calculate_figure_canvas_width(self, thumbnail): """ Calculate the witdh the thumbnail's figure canvas need to have for the thumbnail to fit the scrollarea. """ extra_padding = 10 if sys.platform == 'darwin' else 0 figure_canvas_width = (self.scrollarea.width() - 2 * self.lineWidth() - self.scrollarea.viewportMargins().left() - self.scrollarea.viewportMargins().right() - thumbnail.savefig_btn.width() - thumbnail.layout().spacing() - extra_padding) if is_dark_interface(): # This is required to take into account some hard-coded padding # and margin in qdarkstyle. figure_canvas_width = figure_canvas_width - 6 return figure_canvas_width def _setup_thumbnail_size(self, thumbnail): """ Scale the thumbnail's canvas size so that it fits the thumbnail scrollbar's width. """ max_canvas_size = self._calculate_figure_canvas_width(thumbnail) thumbnail.scale_canvas_size(max_canvas_size) def _update_thumbnail_size(self): """ Update the thumbnails size so that their width fit that of the scrollarea. """ # NOTE: We hide temporarily the thumbnails to prevent a repaint of # each thumbnail as soon as their size is updated in the loop, which # causes some flickering of the thumbnail scrollbar resizing animation. # Once the size of all the thumbnails has been updated, we show them # back so that they are repainted all at once instead of one after the # other. This is just a trick to make the resizing animation of the # thumbnail scrollbar look smoother. self.view.hide() for thumbnail in self._thumbnails: self._setup_thumbnail_size(thumbnail) self.view.show() def add_thumbnail(self, fig, fmt): """ Add a new thumbnail to that thumbnail scrollbar. """ thumbnail = FigureThumbnail(parent=self, background_color=self.background_color) thumbnail.canvas.load_figure(fig, fmt) thumbnail.sig_canvas_clicked.connect(self.set_current_thumbnail) thumbnail.sig_remove_figure.connect(self.remove_thumbnail) thumbnail.sig_save_figure.connect(self.save_figure_as) self._thumbnails.append(thumbnail) self.scene.setRowStretch(self.scene.rowCount() - 1, 0) self.scene.addWidget(thumbnail, self.scene.rowCount() - 1, 0) self.scene.setRowStretch(self.scene.rowCount(), 100) self.set_current_thumbnail(thumbnail) thumbnail.show() self._setup_thumbnail_size(thumbnail) def remove_current_thumbnail(self): """Remove the currently selected thumbnail.""" if self.current_thumbnail is not None: self.remove_thumbnail(self.current_thumbnail) def remove_all_thumbnails(self): """Remove all thumbnails.""" for thumbnail in self._thumbnails: self.layout().removeWidget(thumbnail) thumbnail.sig_canvas_clicked.disconnect() thumbnail.sig_remove_figure.disconnect() thumbnail.sig_save_figure.disconnect() thumbnail.setParent(None) self._thumbnails = [] self.current_thumbnail = None self.figure_viewer.figcanvas.clear_canvas() def remove_thumbnail(self, thumbnail): """Remove thumbnail.""" if thumbnail in self._thumbnails: index = self._thumbnails.index(thumbnail) self._thumbnails.remove(thumbnail) self.layout().removeWidget(thumbnail) thumbnail.setParent(None) thumbnail.sig_canvas_clicked.disconnect() thumbnail.sig_remove_figure.disconnect() thumbnail.sig_save_figure.disconnect() # Select a new thumbnail if any : if thumbnail == self.current_thumbnail: if len(self._thumbnails) > 0: self.set_current_index(min(index, len(self._thumbnails) - 1)) else: self.current_thumbnail = None self.figure_viewer.figcanvas.clear_canvas() def set_current_index(self, index): """Set the currently selected thumbnail by its index.""" self.set_current_thumbnail(self._thumbnails[index]) def get_current_index(self): """Return the index of the currently selected thumbnail.""" try: return self._thumbnails.index(self.current_thumbnail) except ValueError: return -1 def set_current_thumbnail(self, thumbnail): """Set the currently selected thumbnail.""" self.current_thumbnail = thumbnail self.figure_viewer.load_figure(thumbnail.canvas.fig, thumbnail.canvas.fmt) for thumbnail in self._thumbnails: thumbnail.highlight_canvas(thumbnail == self.current_thumbnail) def go_previous_thumbnail(self): """Select the thumbnail previous to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) - 1 index = index if index >= 0 else len(self._thumbnails) - 1 self.set_current_index(index) self.scroll_to_item(index) def go_next_thumbnail(self): """Select thumbnail next to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) + 1 index = 0 if index >= len(self._thumbnails) else index self.set_current_index(index) self.scroll_to_item(index) def scroll_to_item(self, index): """Scroll to the selected item of ThumbnailScrollBar.""" spacing_between_items = self.scene.verticalSpacing() height_view = self.scrollarea.viewport().height() height_item = self.scene.itemAt(index).sizeHint().height() height_view_excluding_item = max(0, height_view - height_item) height_of_top_items = spacing_between_items for i in range(index): item = self.scene.itemAt(i) height_of_top_items += item.sizeHint().height() height_of_top_items += spacing_between_items pos_scroll = height_of_top_items - height_view_excluding_item // 2 vsb = self.scrollarea.verticalScrollBar() vsb.setValue(pos_scroll) # ---- ScrollBar Handlers def go_up(self): """Scroll the scrollbar of the scrollarea up by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() - vsb.singleStep())) def go_down(self): """Scroll the scrollbar of the scrollarea down by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() + vsb.singleStep()))
def setup_ui(self): # Create the main layout main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setMargin(0) main_layout.setSpacing(0) self.setLayout(main_layout) # Create a Label to be the title lbl_title = QLabel("Motors Diagnostic") # Add some StyleSheet to it lbl_title.setStyleSheet("\ QLabel {\ qproperty-alignment: AlignCenter;\ border: 1px solid #FF17365D;\ border-top-left-radius: 15px;\ border-top-right-radius: 15px;\ background-color: #FF17365D;\ padding: 0px 0px;\ color: rgb(255, 255, 255);\ max-height: 25px;\ font-size: 14px;\ }") # Add the title label to the main layout main_layout.addWidget(lbl_title) # Create the Search Panel layout search_layout = QHBoxLayout() search_layout.setContentsMargins(0, 0, 0, 0) search_layout.setMargin(0) search_layout.setSpacing(0) # Create a GroupBox with "Filtering" as Title gb_search = QGroupBox(parent=self) gb_search.setTitle("Filtering") gb_search.setLayout(search_layout) # Create a label, line edit and button for filtering lbl_search = QLabel(text="Filter: ") self.txt_filter = QLineEdit() self.txt_filter.returnPressed.connect(self.do_search) btn_search = QPushButton() btn_search.setText("Search") btn_search.clicked.connect(self.do_search) # Add the created widgets to the layout search_layout.addWidget(lbl_search) search_layout.addWidget(self.txt_filter) search_layout.addWidget(btn_search) # Add the Groupbox to the main layout main_layout.addWidget(gb_search) # Create the Results Layout self.results_layout = QVBoxLayout() self.results_layout.setContentsMargins(0, 0, 0, 0) results_layout.setMargin(0) results_layout.setSpacing(0) # Create a Frame to host the results of search self.frm_result = QFrame(parent=self) self.frm_result.setLayout(self.results_layout) # Create a ScrollArea so we can properly handle # many entries scroll_area = QScrollArea(parent=self) scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) scroll_area.setWidgetResizable(True) # Add the Frame to the scroll area scroll_area.setWidget(self.frm_result) # Add the scroll area to the main layout main_layout.addWidget(scroll_area)
class ThumbnailScrollBar(QFrame): """ A widget that manages the display of the FigureThumbnails that are created when a figure is sent to the IPython console by the kernel and that controls what is displayed in the FigureViewer. """ redirect_stdio = Signal(bool) def __init__(self, figure_viewer, parent=None, background_color=None): super(ThumbnailScrollBar, self).__init__(parent) self._thumbnails = [] self.background_color = background_color self.current_thumbnail = None self.set_figureviewer(figure_viewer) self.setup_gui() def setup_gui(self): """Setup the main layout of the widget.""" scrollarea = self.setup_scrollarea() up_btn, down_btn = self.setup_arrow_buttons() self.setFixedWidth(150) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(up_btn) layout.addWidget(scrollarea) layout.addWidget(down_btn) def setup_scrollarea(self): """Setup the scrollarea that will contain the FigureThumbnails.""" self.view = QWidget() self.scene = QGridLayout(self.view) self.scene.setColumnStretch(0, 100) self.scene.setColumnStretch(2, 100) self.scrollarea = QScrollArea() self.scrollarea.setWidget(self.view) self.scrollarea.setWidgetResizable(True) self.scrollarea.setFrameStyle(0) self.scrollarea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scrollarea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scrollarea.setSizePolicy( QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)) # Set the vertical scrollbar explicitely : # This is required to avoid a "RuntimeError: no access to protected # functions or signals for objects not created from Python" in Linux. self.scrollarea.setVerticalScrollBar(QScrollBar()) return self.scrollarea def setup_arrow_buttons(self): """ Setup the up and down arrow buttons that are placed at the top and bottom of the scrollarea. """ # Get the height of the up/down arrow of the default vertical # scrollbar : vsb = self.scrollarea.verticalScrollBar() style = vsb.style() opt = QStyleOptionSlider() vsb.initStyleOption(opt) vsb_up_arrow = style.subControlRect(QStyle.CC_ScrollBar, opt, QStyle.SC_ScrollBarAddLine, self) # Setup the up and down arrow button : up_btn = up_btn = QPushButton(icon=ima.icon('last_edit_location')) up_btn.setFlat(True) up_btn.setFixedHeight(vsb_up_arrow.size().height()) up_btn.clicked.connect(self.go_up) down_btn = QPushButton(icon=ima.icon('folding.arrow_down_on')) down_btn.setFlat(True) down_btn.setFixedHeight(vsb_up_arrow.size().height()) down_btn.clicked.connect(self.go_down) return up_btn, down_btn def set_figureviewer(self, figure_viewer): """Set the bamespace for the FigureViewer.""" self.figure_viewer = figure_viewer # ---- Save Figure def save_all_figures_as(self): """Save all the figures to a file.""" self.redirect_stdio.emit(False) dirname = getexistingdirectory(self, caption='Save all figures', basedir=getcwd_or_home()) self.redirect_stdio.emit(True) if dirname: self.save_all_figures_todir(dirname) def save_all_figures_todir(self, dirname): """Save all figure in dirname.""" for thumbnail in self._thumbnails: fig = thumbnail.canvas.fig fmt = thumbnail.canvas.fmt fext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/svg+xml': '.svg' }[fmt] figname = get_unique_figname(dirname, 'Figure', fext) save_figure_tofile(fig, fmt, figname) def save_current_figure_as(self): """Save the currently selected figure.""" if self.current_thumbnail is None: return self.save_figure_as(self.current_thumbnail.canvas.fig, self.current_thumbnail.canvas.fmt) def save_figure_as(self, fig, fmt): """Save the figure to a file.""" fext, ffilt = { 'image/png': ('.png', 'PNG (*.png)'), 'image/jpeg': ('.jpg', 'JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)'), 'image/svg+xml': ('.svg', 'SVG (*.svg);;PNG (*.png)') }[fmt] figname = get_unique_figname(getcwd_or_home(), 'Figure', fext) self.redirect_stdio.emit(False) fname, fext = getsavefilename(parent=self.parent(), caption='Save Figure', basedir=figname, filters=ffilt, selectedfilter='', options=None) self.redirect_stdio.emit(True) if fname: save_figure_tofile(fig, fmt, fname) # ---- Thumbails Handlers def add_thumbnail(self, fig, fmt): thumbnail = FigureThumbnail(background_color=self.background_color) thumbnail.canvas.load_figure(fig, fmt) # Scale the thumbnail size, while respecting the figure # dimension ratio. fwidth = thumbnail.canvas.fwidth fheight = thumbnail.canvas.fheight max_length = 100 if fwidth / fheight > 1: canvas_width = max_length canvas_height = canvas_width / fwidth * fheight else: canvas_height = max_length canvas_width = canvas_height / fheight * fwidth thumbnail.canvas.setFixedSize(canvas_width, canvas_height) thumbnail.sig_canvas_clicked.connect(self.set_current_thumbnail) thumbnail.sig_remove_figure.connect(self.remove_thumbnail) thumbnail.sig_save_figure.connect(self.save_figure_as) self._thumbnails.append(thumbnail) self.scene.setRowStretch(self.scene.rowCount() - 1, 0) self.scene.addWidget(thumbnail, self.scene.rowCount() - 1, 1) self.scene.setRowStretch(self.scene.rowCount(), 100) self.set_current_thumbnail(thumbnail) def remove_current_thumbnail(self): """Remove the currently selected thumbnail.""" if self.current_thumbnail is not None: self.remove_thumbnail(self.current_thumbnail) def remove_all_thumbnails(self): """Remove all thumbnails.""" for thumbnail in self._thumbnails: self.layout().removeWidget(thumbnail) thumbnail.deleteLater() self._thumbnails = [] self.current_thumbnail = None self.figure_viewer.figcanvas.clear_canvas() def remove_thumbnail(self, thumbnail): """Remove thumbnail.""" if thumbnail in self._thumbnails: index = self._thumbnails.index(thumbnail) self._thumbnails.remove(thumbnail) self.layout().removeWidget(thumbnail) thumbnail.deleteLater() # Select a new thumbnail if any : if thumbnail == self.current_thumbnail: if len(self._thumbnails) > 0: self.set_current_index(min(index, len(self._thumbnails) - 1)) else: self.current_thumbnail = None self.figure_viewer.figcanvas.clear_canvas() def set_current_index(self, index): """Set the currently selected thumbnail by its index.""" self.set_current_thumbnail(self._thumbnails[index]) def set_current_thumbnail(self, thumbnail): """Set the currently selected thumbnail.""" self.current_thumbnail = thumbnail self.figure_viewer.load_figure(thumbnail.canvas.fig, thumbnail.canvas.fmt) for thumbnail in self._thumbnails: thumbnail.highlight_canvas(thumbnail == self.current_thumbnail) def go_previous_thumbnail(self): """Select the thumbnail previous to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) - 1 index = index if index >= 0 else len(self._thumbnails) - 1 self.set_current_index(index) def go_next_thumbnail(self): """Select thumbnail next to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) + 1 index = 0 if index >= len(self._thumbnails) else index self.set_current_index(index) # ---- ScrollBar Handlers def go_up(self): """Scroll the scrollbar of the scrollarea up by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() - vsb.singleStep())) def go_down(self): """Scroll the scrollbar of the scrollarea down by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() + vsb.singleStep()))
class LibraryWidget(QWidget): sigImageChanged = Signal() def __init__(self): super(LibraryWidget, self).__init__() self.image_items = [] self.views = [] self.current_image_item = None self.setLayout(QHBoxLayout()) self.right_layout = QVBoxLayout() self.scroll_widget = QScrollArea() self.scroll_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) self.scroll_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scroll_widget.setWidgetResizable(True) self.flow_widget = QWidget() self.flow_layout = FlowLayout() self.flow_widget.setLayout(self.flow_layout) self.scroll_widget.setWidget(self.flow_widget) self.layout().addWidget(self.scroll_widget) self.hist_widget = HistogramLUTWidget() self.hist_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) self.hist_widget.item.sigLevelChangeFinished.connect(self.set_levels) self.hist_widget.item.sigLookupTableChanged.connect( self.set_lookup_table) self.layout().addLayout(self.right_layout) self.right_layout.addWidget(self.hist_widget) self.link_button = QPushButton("Link Axes") self.link_button.setCheckable(True) self.right_layout.addWidget(self.link_button) # TODO: use Qt styling pallet for theming # self.setStyleSheet("background-color:#000;") self.current_view = None self.axes_linked = False def set_slice(self, *args, **kwargs): # TODO: support generic orthogonal slicing print('slice:', args, kwargs) def set_levels(self, *args, **kwargs): levels = self.hist_widget.item.getLevels() for image_item in self.image_items: image_item.setLevels(levels) def set_lookup_table(self, *args, **kwargs): if self.current_image_item and self.current_image_item.image is not None: lut = self.hist_widget.item.getLookupTable( self.current_image_item.image) for image_item in self.image_items: image_item.setLookupTable(lut) def set_current_imageitem(self, imageitem: ImageItem): if self.current_image_item: self.current_image_item.deactivate() self.current_image_item = imageitem self.current_view = imageitem.getViewBox() self.current_image_item.activate() self.hist_widget.item.setImageItem(self.current_image_item) def propagate_axes(self): if self.link_button.isChecked(): view = self.sender() view_rect = view.viewRect() for other_view in self.views: if other_view is not view: with QSignalBlocker(other_view): other_view.setRange(rect=view_rect, padding=0) def add_image(self, image, label): w = QFrame() w.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken) w.setLineWidth(2) w.setFixedSize(QSize(500, 500)) w.setLayout(QVBoxLayout()) gv = ScrollableGraphicsLayoutWidget() vb = ViewBox(lockAspect=True) ii = ActivatableImageItem(image=image) ii.sigActivated.connect(self.set_current_imageitem) self.hist_widget.item.setImageItem(ii) self.current_image_item = ii self.image_items.append(ii) self.views.append(vb) vb.sigRangeChangedManually.connect(self.propagate_axes) vb.addItem(ii) gv.addItem(vb) self.set_current_imageitem(ii) w.layout().addWidget(gv) l = QLabel(label) # l.setStyleSheet("color: white;") w.layout().addWidget(l) self.flow_layout.addWidget(w) self.last_vb = vb def update_image(self, index, image, label): if index < len(self.image_items): self.image_items[index].setImage(image) else: self.add_image(image, label)
class TimeChartDisplay(Display): def __init__(self, parent=None, args=[], macros=None, show_pv_add_panel=True, config_file=None): """ Create all the widgets, including any child dialogs. Parameters ---------- parent : QWidget The parent widget of the charting display args : list The command parameters macros : str Macros to modify the UI parameters at runtime show_pv_add_panel : bool Whether or not to show the PV add panel on top of the graph """ super(TimeChartDisplay, self).__init__(parent=parent, args=args, macros=macros) self.legend_font = None self.channel_map = dict() self.setWindowTitle("TimeChart Tool") self.main_layout = QVBoxLayout() self.body_layout = QVBoxLayout() self.pv_add_panel = QFrame() self.pv_add_panel.setVisible(show_pv_add_panel) self.pv_add_panel.setMaximumHeight(50) self.pv_layout = QHBoxLayout() self.pv_name_line_edt = QLineEdit() self.pv_name_line_edt.setAcceptDrops(True) self.pv_name_line_edt.returnPressed.connect(self.add_curve) self.pv_protocol_cmb = QComboBox() self.pv_protocol_cmb.addItems(["ca://", "archive://"]) self.pv_protocol_cmb.setEnabled(False) self.pv_connect_push_btn = QPushButton("Connect") self.pv_connect_push_btn.clicked.connect(self.add_curve) self.tab_panel = QTabWidget() self.tab_panel.setMinimumWidth(350) self.tab_panel.setMaximumWidth(350) self.curve_settings_tab = QWidget() self.data_settings_tab = QWidget() self.chart_settings_tab = QWidget() self.charting_layout = QHBoxLayout() self.chart = PyDMTimePlot(plot_by_timestamps=False) self.chart.setDownsampling(ds=False, auto=False, mode=None) self.chart.plot_redrawn_signal.connect(self.update_curve_data) self.chart.setBufferSize(DEFAULT_BUFFER_SIZE) self.chart.setPlotTitle(DEFAULT_CHART_TITLE) self.splitter = QSplitter() self.curve_settings_layout = QVBoxLayout() self.curve_settings_layout.setAlignment(Qt.AlignTop) self.curve_settings_layout.setSizeConstraint(QLayout.SetMinAndMaxSize) self.curve_settings_layout.setSpacing(5) self.crosshair_settings_layout = QVBoxLayout() self.crosshair_settings_layout.setAlignment(Qt.AlignTop) self.crosshair_settings_layout.setSpacing(5) self.enable_crosshair_chk = QCheckBox("Crosshair") self.crosshair_coord_lbl = QLabel() self.crosshair_coord_lbl.setWordWrap(True) self.curve_settings_inner_frame = QFrame() self.curve_settings_inner_frame.setLayout(self.curve_settings_layout) self.curve_settings_scroll = QScrollArea() self.curve_settings_scroll.setVerticalScrollBarPolicy( Qt.ScrollBarAsNeeded) self.curve_settings_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarAlwaysOff) self.curve_settings_scroll.setWidget(self.curve_settings_inner_frame) self.curve_settings_scroll.setWidgetResizable(True) self.enable_crosshair_chk.setChecked(False) self.enable_crosshair_chk.clicked.connect( self.handle_enable_crosshair_checkbox_clicked) self.enable_crosshair_chk.clicked.emit(False) self.curves_tab_layout = QHBoxLayout() self.curves_tab_layout.addWidget(self.curve_settings_scroll) self.data_tab_layout = QVBoxLayout() self.data_tab_layout.setAlignment(Qt.AlignTop) self.data_tab_layout.setSpacing(5) self.chart_settings_layout = QVBoxLayout() self.chart_settings_layout.setAlignment(Qt.AlignTop) self.chart_settings_layout.setSpacing(5) self.chart_layout = QVBoxLayout() self.chart_layout.setSpacing(10) self.chart_panel = QWidget() self.chart_panel.setMinimumHeight(400) self.chart_control_layout = QHBoxLayout() self.chart_control_layout.setAlignment(Qt.AlignHCenter) self.chart_control_layout.setSpacing(10) self.zoom_x_layout = QVBoxLayout() self.zoom_x_layout.setAlignment(Qt.AlignTop) self.zoom_x_layout.setSpacing(5) self.plus_icon = IconFont().icon("plus", color=QColor("green")) self.minus_icon = IconFont().icon("minus", color=QColor("red")) self.view_all_icon = IconFont().icon("globe", color=QColor("blue")) self.reset_icon = IconFont().icon("circle-o-notch", color=QColor("green")) self.zoom_in_x_btn = QPushButton("X Zoom") self.zoom_in_x_btn.setIcon(self.plus_icon) self.zoom_in_x_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "x", True)) self.zoom_in_x_btn.setEnabled(False) self.zoom_out_x_btn = QPushButton("X Zoom") self.zoom_out_x_btn.setIcon(self.minus_icon) self.zoom_out_x_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "x", False)) self.zoom_out_x_btn.setEnabled(False) self.zoom_y_layout = QVBoxLayout() self.zoom_y_layout.setAlignment(Qt.AlignTop) self.zoom_y_layout.setSpacing(5) self.zoom_in_y_btn = QPushButton("Y Zoom") self.zoom_in_y_btn.setIcon(self.plus_icon) self.zoom_in_y_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "y", True)) self.zoom_in_y_btn.setEnabled(False) self.zoom_out_y_btn = QPushButton("Y Zoom") self.zoom_out_y_btn.setIcon(self.minus_icon) self.zoom_out_y_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "y", False)) self.zoom_out_y_btn.setEnabled(False) self.view_all_btn = QPushButton("View All") self.view_all_btn.setIcon(self.view_all_icon) self.view_all_btn.clicked.connect(self.handle_view_all_button_clicked) self.view_all_btn.setEnabled(False) self.view_all_reset_chart_layout = QVBoxLayout() self.view_all_reset_chart_layout.setAlignment(Qt.AlignTop) self.view_all_reset_chart_layout.setSpacing(5) self.pause_chart_layout = QVBoxLayout() self.pause_chart_layout.setAlignment(Qt.AlignTop) self.pause_chart_layout.setSpacing(5) self.reset_chart_btn = QPushButton("Reset") self.reset_chart_btn.setIcon(self.reset_icon) self.reset_chart_btn.clicked.connect( self.handle_reset_chart_btn_clicked) self.reset_chart_btn.setEnabled(False) self.pause_icon = IconFont().icon("pause", color=QColor("red")) self.play_icon = IconFont().icon("play", color=QColor("green")) self.pause_chart_btn = QPushButton() self.pause_chart_btn.setIcon(self.pause_icon) self.pause_chart_btn.clicked.connect( self.handle_pause_chart_btn_clicked) self.title_settings_layout = QVBoxLayout() self.title_settings_layout.setAlignment(Qt.AlignTop) self.title_settings_layout.setSpacing(5) self.title_settings_grpbx = QGroupBox("Title and Legend") self.title_settings_grpbx.setMaximumHeight(120) self.import_export_data_layout = QVBoxLayout() self.import_export_data_layout.setAlignment(Qt.AlignTop) self.import_export_data_layout.setSpacing(5) self.import_data_btn = QPushButton("Import...") self.import_data_btn.clicked.connect( self.handle_import_data_btn_clicked) self.export_data_btn = QPushButton("Export...") self.export_data_btn.clicked.connect( self.handle_export_data_btn_clicked) self.chart_title_layout = QHBoxLayout() self.chart_title_layout.setSpacing(10) self.chart_title_lbl = QLabel(text="Graph Title") self.chart_title_line_edt = QLineEdit() self.chart_title_line_edt.setText(self.chart.getPlotTitle()) self.chart_title_line_edt.textChanged.connect( self.handle_title_text_changed) self.chart_title_font_btn = QPushButton() self.chart_title_font_btn.setFixedHeight(24) self.chart_title_font_btn.setFixedWidth(24) self.chart_title_font_btn.setIcon(IconFont().icon("font")) self.chart_title_font_btn.clicked.connect( partial(self.handle_chart_font_changed, "title")) self.chart_change_axis_settings_btn = QPushButton( text="Change Axis Settings...") self.chart_change_axis_settings_btn.clicked.connect( self.handle_change_axis_settings_clicked) self.update_datetime_timer = QTimer(self) self.update_datetime_timer.timeout.connect( self.handle_update_datetime_timer_timeout) self.chart_sync_mode_layout = QVBoxLayout() self.chart_sync_mode_layout.setSpacing(5) self.chart_sync_mode_grpbx = QGroupBox("Data Sampling Mode") self.chart_sync_mode_grpbx.setMaximumHeight(100) self.chart_sync_mode_sync_radio = QRadioButton("Synchronous") self.chart_sync_mode_async_radio = QRadioButton("Asynchronous") self.chart_sync_mode_async_radio.setChecked(True) self.graph_drawing_settings_layout = QVBoxLayout() self.graph_drawing_settings_layout.setAlignment(Qt.AlignVCenter) self.chart_interval_layout = QFormLayout() self.chart_redraw_rate_lbl = QLabel("Redraw Rate (Hz)") self.chart_redraw_rate_spin = QSpinBox() self.chart_redraw_rate_spin.setRange(MIN_REDRAW_RATE_HZ, MAX_REDRAW_RATE_HZ) self.chart_redraw_rate_spin.setValue(DEFAULT_REDRAW_RATE_HZ) self.chart_redraw_rate_spin.editingFinished.connect( self.handle_redraw_rate_changed) self.chart_data_sampling_rate_lbl = QLabel("Data Sampling Rate (Hz)") self.chart_data_async_sampling_rate_spin = QSpinBox() self.chart_data_async_sampling_rate_spin.setRange( MIN_DATA_SAMPLING_RATE_HZ, MAX_DATA_SAMPLING_RATE_HZ) self.chart_data_async_sampling_rate_spin.setValue( DEFAULT_DATA_SAMPLING_RATE_HZ) self.chart_data_async_sampling_rate_spin.editingFinished.connect( self.handle_data_sampling_rate_changed) self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart_limit_time_span_layout = QHBoxLayout() self.chart_limit_time_span_layout.setSpacing(5) self.limit_time_plan_text = "Limit Time Span" self.chart_limit_time_span_chk = QCheckBox(self.limit_time_plan_text) self.chart_limit_time_span_chk.hide() self.chart_limit_time_span_lbl = QLabel("Hr:Min:Sec") self.chart_limit_time_span_hours_spin_box = QSpinBox() self.chart_limit_time_span_hours_spin_box.setMaximum(999) self.chart_limit_time_span_minutes_spin_box = QSpinBox() self.chart_limit_time_span_minutes_spin_box.setMaximum(59) self.chart_limit_time_span_seconds_spin_box = QSpinBox() self.chart_limit_time_span_seconds_spin_box.setMaximum(59) self.chart_limit_time_span_activate_btn = QPushButton("Apply") self.chart_limit_time_span_activate_btn.setDisabled(True) self.chart_ring_buffer_layout = QFormLayout() self.chart_ring_buffer_size_lbl = QLabel("Ring Buffer Size") self.chart_ring_buffer_size_edt = QLineEdit() self.chart_ring_buffer_size_edt.returnPressed.connect( self.handle_buffer_size_changed) self.chart_ring_buffer_size_edt.setText(str(DEFAULT_BUFFER_SIZE)) self.show_legend_chk = QCheckBox("Show Legend") self.show_legend_chk.clicked.connect( self.handle_show_legend_checkbox_clicked) self.show_legend_chk.setChecked(self.chart.showLegend) self.legend_font_btn = QPushButton() self.legend_font_btn.setFixedHeight(24) self.legend_font_btn.setFixedWidth(24) self.legend_font_btn.setIcon(IconFont().icon("font")) self.legend_font_btn.clicked.connect( partial(self.handle_chart_font_changed, "legend")) self.graph_background_color_layout = QFormLayout() self.axis_grid_color_layout = QFormLayout() self.background_color_lbl = QLabel("Graph Background Color ") self.background_color_btn = QPushButton() self.background_color_btn.setStyleSheet( "background-color: " + self.chart.getBackgroundColor().name()) self.background_color_btn.setContentsMargins(10, 0, 5, 5) self.background_color_btn.setMaximumWidth(20) self.background_color_btn.clicked.connect( self.handle_background_color_button_clicked) self.axis_settings_layout = QVBoxLayout() self.axis_settings_layout.setSpacing(10) self.show_x_grid_chk = QCheckBox("Show x Grid") self.show_x_grid_chk.setChecked(self.chart.showXGrid) self.show_x_grid_chk.clicked.connect( self.handle_show_x_grid_checkbox_clicked) self.show_y_grid_chk = QCheckBox("Show y Grid") self.show_y_grid_chk.setChecked(self.chart.showYGrid) self.show_y_grid_chk.clicked.connect( self.handle_show_y_grid_checkbox_clicked) self.axis_color_lbl = QLabel("Axis and Grid Color") self.axis_color_btn = QPushButton() self.axis_color_btn.setStyleSheet("background-color: " + DEFAULT_CHART_AXIS_COLOR.name()) self.axis_color_btn.setContentsMargins(10, 0, 5, 5) self.axis_color_btn.setMaximumWidth(20) self.axis_color_btn.clicked.connect( self.handle_axis_color_button_clicked) self.grid_opacity_lbl = QLabel("Grid Opacity") self.grid_opacity_lbl.setEnabled(False) self.grid_opacity_slr = QSlider(Qt.Horizontal) self.grid_opacity_slr.setFocusPolicy(Qt.StrongFocus) self.grid_opacity_slr.setRange(0, 10) self.grid_opacity_slr.setValue(5) self.grid_opacity_slr.setTickInterval(1) self.grid_opacity_slr.setSingleStep(1) self.grid_opacity_slr.setTickPosition(QSlider.TicksBelow) self.grid_opacity_slr.valueChanged.connect( self.handle_grid_opacity_slider_mouse_release) self.grid_opacity_slr.setEnabled(False) self.reset_data_settings_btn = QPushButton("Reset Data Settings") self.reset_data_settings_btn.clicked.connect( self.handle_reset_data_settings_btn_clicked) self.reset_chart_settings_btn = QPushButton("Reset Chart Settings") self.reset_chart_settings_btn.clicked.connect( self.handle_reset_chart_settings_btn_clicked) self.curve_checkbox_panel = QWidget() self.graph_drawing_settings_grpbx = QGroupBox("Graph Intervals") self.graph_drawing_settings_grpbx.setAlignment(Qt.AlignTop) self.axis_settings_grpbx = QGroupBox("Graph Appearance") self.app = QApplication.instance() self.setup_ui() self.curve_settings_disp = None self.axis_settings_disp = None self.chart_data_export_disp = None self.chart_data_import_disp = None self.grid_alpha = 5 self.time_span_limit_hours = None self.time_span_limit_minutes = None self.time_span_limit_seconds = None self.data_sampling_mode = ASYNC_DATA_SAMPLING # If there is an imported config file, let's start TimeChart with the imported configuration data if config_file: importer = SettingsImporter(self) try: importer.import_settings(config_file) except SettingsImporterException: display_message_box( QMessageBox.Critical, "Import Failure", "Cannot import the file '{0}'. Check the log for the error details." .format(config_file)) logger.exception( "Cannot import the file '{0}'.".format(config_file)) def ui_filepath(self): """ The path to the UI file created by Qt Designer, if applicable. """ # No UI file is being used return None def ui_filename(self): """ The name the UI file created by Qt Designer, if applicable. """ # No UI file is being used return None def setup_ui(self): """ Initialize the widgets and layouts. """ self.setLayout(self.main_layout) self.pv_layout.addWidget(self.pv_protocol_cmb) self.pv_layout.addWidget(self.pv_name_line_edt) self.pv_layout.addWidget(self.pv_connect_push_btn) self.pv_add_panel.setLayout(self.pv_layout) QTimer.singleShot(0, self.pv_name_line_edt.setFocus) self.curve_settings_tab.setLayout(self.curves_tab_layout) self.chart_settings_tab.setLayout(self.chart_settings_layout) self.setup_chart_settings_layout() self.data_settings_tab.setLayout(self.data_tab_layout) self.setup_data_tab_layout() self.tab_panel.addTab(self.curve_settings_tab, "Curves") self.tab_panel.addTab(self.data_settings_tab, "Data") self.tab_panel.addTab(self.chart_settings_tab, "Graph") self.crosshair_settings_layout.addWidget(self.enable_crosshair_chk) self.crosshair_settings_layout.addWidget(self.crosshair_coord_lbl) self.zoom_x_layout.addWidget(self.zoom_in_x_btn) self.zoom_x_layout.addWidget(self.zoom_out_x_btn) self.zoom_y_layout.addWidget(self.zoom_in_y_btn) self.zoom_y_layout.addWidget(self.zoom_out_y_btn) self.view_all_reset_chart_layout.addWidget(self.reset_chart_btn) self.view_all_reset_chart_layout.addWidget(self.view_all_btn) self.pause_chart_layout.addWidget(self.pause_chart_btn) self.import_export_data_layout.addWidget(self.import_data_btn) self.import_export_data_layout.addWidget(self.export_data_btn) self.chart_control_layout.addLayout(self.zoom_x_layout) self.chart_control_layout.addLayout(self.zoom_y_layout) self.chart_control_layout.addLayout(self.view_all_reset_chart_layout) self.chart_control_layout.addLayout(self.pause_chart_layout) self.chart_control_layout.addLayout(self.crosshair_settings_layout) self.chart_control_layout.addLayout(self.import_export_data_layout) self.chart_control_layout.insertSpacing(5, 30) self.chart_layout.addWidget(self.chart) self.chart_layout.addLayout(self.chart_control_layout) self.chart_panel.setLayout(self.chart_layout) self.splitter.addWidget(self.chart_panel) self.splitter.addWidget(self.tab_panel) self.splitter.setSizes([1, 0]) self.splitter.setHandleWidth(10) self.splitter.setStretchFactor(0, 0) self.splitter.setStretchFactor(1, 1) self.charting_layout.addWidget(self.splitter) self.body_layout.addWidget(self.pv_add_panel) self.body_layout.addLayout(self.charting_layout) self.body_layout.setSpacing(0) self.body_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.addLayout(self.body_layout) self.enable_chart_control_buttons(False) handle = self.splitter.handle(1) layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) button = QToolButton(handle) button.setArrowType(Qt.LeftArrow) button.clicked.connect(lambda: self.handle_splitter_button(True)) layout.addWidget(button) button = QToolButton(handle) button.setArrowType(Qt.RightArrow) button.clicked.connect(lambda: self.handle_splitter_button(False)) layout.addWidget(button) handle.setLayout(layout) def handle_splitter_button(self, left=True): if left: self.splitter.setSizes([1, 1]) else: self.splitter.setSizes([1, 0]) def change_legend_font(self, font): if font is None: return self.legend_font = font items = self.chart.plotItem.legend.items for i in items: i[1].item.setFont(font) i[1].resizeEvent(None) i[1].updateGeometry() def change_title_font(self, font): current_text = self.chart.plotItem.titleLabel.text args = { "family": font.family, "size": "{}pt".format(font.pointSize()), "bold": font.bold(), "italic": font.italic(), } self.chart.plotItem.titleLabel.setText(current_text, **args) def handle_chart_font_changed(self, target): if target not in ("title", "legend"): return dialog = QFontDialog(self) dialog.setOption(QFontDialog.DontUseNativeDialog, True) if target == "title": dialog.fontSelected.connect(self.change_title_font) else: dialog.fontSelected.connect(self.change_legend_font) dialog.open() def setup_data_tab_layout(self): self.chart_sync_mode_sync_radio.toggled.connect( partial(self.handle_sync_mode_radio_toggle, self.chart_sync_mode_sync_radio)) self.chart_sync_mode_async_radio.toggled.connect( partial(self.handle_sync_mode_radio_toggle, self.chart_sync_mode_async_radio)) self.chart_sync_mode_layout.addWidget(self.chart_sync_mode_sync_radio) self.chart_sync_mode_layout.addWidget(self.chart_sync_mode_async_radio) self.chart_sync_mode_grpbx.setLayout(self.chart_sync_mode_layout) self.data_tab_layout.addWidget(self.chart_sync_mode_grpbx) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_lbl) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_hours_spin_box) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_minutes_spin_box) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_seconds_spin_box) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_activate_btn) self.chart_limit_time_span_lbl.hide() self.chart_limit_time_span_hours_spin_box.hide() self.chart_limit_time_span_minutes_spin_box.hide() self.chart_limit_time_span_seconds_spin_box.hide() self.chart_limit_time_span_activate_btn.hide() self.chart_limit_time_span_hours_spin_box.valueChanged.connect( self.handle_time_span_changed) self.chart_limit_time_span_minutes_spin_box.valueChanged.connect( self.handle_time_span_changed) self.chart_limit_time_span_seconds_spin_box.valueChanged.connect( self.handle_time_span_changed) self.chart_limit_time_span_chk.clicked.connect( self.handle_limit_time_span_checkbox_clicked) self.chart_limit_time_span_activate_btn.clicked.connect( self.handle_chart_limit_time_span_activate_btn_clicked) self.chart_interval_layout.addRow(self.chart_redraw_rate_lbl, self.chart_redraw_rate_spin) self.chart_interval_layout.addRow( self.chart_data_sampling_rate_lbl, self.chart_data_async_sampling_rate_spin) self.graph_drawing_settings_layout.addLayout( self.chart_interval_layout) self.graph_drawing_settings_layout.addWidget( self.chart_limit_time_span_chk) self.graph_drawing_settings_layout.addLayout( self.chart_limit_time_span_layout) self.chart_ring_buffer_layout.addRow(self.chart_ring_buffer_size_lbl, self.chart_ring_buffer_size_edt) self.graph_drawing_settings_layout.addLayout( self.chart_ring_buffer_layout) self.graph_drawing_settings_grpbx.setLayout( self.graph_drawing_settings_layout) self.data_tab_layout.addWidget(self.graph_drawing_settings_grpbx) self.chart_sync_mode_async_radio.toggled.emit(True) self.data_tab_layout.addWidget(self.reset_data_settings_btn) def setup_chart_settings_layout(self): self.chart_title_layout.addWidget(self.chart_title_lbl) self.chart_title_layout.addWidget(self.chart_title_line_edt) self.chart_title_layout.addWidget(self.chart_title_font_btn) self.title_settings_layout.addLayout(self.chart_title_layout) legend_layout = QHBoxLayout() legend_layout.addWidget(self.show_legend_chk) legend_layout.addWidget(self.legend_font_btn) self.title_settings_layout.addLayout(legend_layout) self.title_settings_layout.addWidget( self.chart_change_axis_settings_btn) self.title_settings_grpbx.setLayout(self.title_settings_layout) self.chart_settings_layout.addWidget(self.title_settings_grpbx) self.graph_background_color_layout.addRow(self.background_color_lbl, self.background_color_btn) self.axis_settings_layout.addLayout(self.graph_background_color_layout) self.axis_grid_color_layout.addRow(self.axis_color_lbl, self.axis_color_btn) self.axis_settings_layout.addLayout(self.axis_grid_color_layout) self.axis_settings_layout.addWidget(self.show_x_grid_chk) self.axis_settings_layout.addWidget(self.show_y_grid_chk) self.axis_settings_layout.addWidget(self.grid_opacity_lbl) self.axis_settings_layout.addWidget(self.grid_opacity_slr) self.axis_settings_grpbx.setLayout(self.axis_settings_layout) self.chart_settings_layout.addWidget(self.axis_settings_grpbx) self.chart_settings_layout.addWidget(self.reset_chart_settings_btn) self.update_datetime_timer.start(1000) def add_curve(self): """ Add a new curve to the chart. """ pv_name = self._get_full_pv_name(self.pv_name_line_edt.text()) if pv_name and len(pv_name): color = random_color(curve_colors_only=True) for k, v in self.channel_map.items(): if color == v.color: color = random_color(curve_colors_only=True) self.add_y_channel(pv_name=pv_name, curve_name=pv_name, color=color) self.handle_splitter_button(left=True) def show_mouse_coordinates(self, x, y): self.crosshair_coord_lbl.clear() self.crosshair_coord_lbl.setText("x = {0:.3f}\ny = {1:.3f}".format( x, y)) def handle_enable_crosshair_checkbox_clicked(self, is_checked): self.chart.enableCrosshair(is_checked) self.crosshair_coord_lbl.setVisible(is_checked) self.chart.crosshair_position_updated.connect( self.show_mouse_coordinates) def add_y_channel(self, pv_name, curve_name, color, line_style=Qt.SolidLine, line_width=2, symbol=None, symbol_size=None, is_visible=True): if pv_name in self.channel_map: logger.error("'{0}' has already been added.".format(pv_name)) return curve = self.chart.addYChannel(y_channel=pv_name, name=curve_name, color=color, lineStyle=line_style, lineWidth=line_width, symbol=symbol, symbolSize=symbol_size) curve.show() if is_visible else curve.hide() if self.show_legend_chk.isChecked(): self.change_legend_font(self.legend_font) self.channel_map[pv_name] = curve self.generate_pv_controls(pv_name, color) self.enable_chart_control_buttons() try: self.app.add_connection(curve.channel) except AttributeError: # these methods are not needed on future versions of pydm pass def generate_pv_controls(self, pv_name, curve_color): """ Generate a set of widgets to manage the appearance of a curve. The set of widgets includes: 1. A checkbox which shows the curve on the chart if checked, and hide the curve if not checked 2. Three buttons -- Modify..., Focus, and Remove. Modify... will bring up the Curve Settings dialog. Focus adjusts the chart's zooming for the current curve. Remove will delete the curve from the chart Parameters ---------- pv_name: str The name of the PV the current curve is being plotted for curve_color : QColor The color of the curve to paint for the checkbox label to help the user track the curve to the checkbox """ individual_curve_layout = QVBoxLayout() size_policy = QSizePolicy() size_policy.setVerticalPolicy(QSizePolicy.Fixed) size_policy.setHorizontalPolicy(QSizePolicy.Fixed) individual_curve_grpbx = QGroupBox() individual_curve_grpbx.setMinimumWidth(300) individual_curve_grpbx.setMinimumHeight(120) individual_curve_grpbx.setAlignment(Qt.AlignTop) individual_curve_grpbx.setSizePolicy(size_policy) individual_curve_grpbx.setObjectName(pv_name + "_grb") individual_curve_grpbx.setLayout(individual_curve_layout) checkbox = QCheckBox(parent=individual_curve_grpbx) checkbox.setObjectName(pv_name + "_chb") palette = checkbox.palette() palette.setColor(QPalette.Active, QPalette.WindowText, curve_color) checkbox.setPalette(palette) display_name = pv_name.split("://")[1] if len(display_name) > MAX_DISPLAY_PV_NAME_LENGTH: # Only display max allowed number of characters of the PV Name display_name = display_name[ :int(MAX_DISPLAY_PV_NAME_LENGTH / 2) - 1] + "..." + \ display_name[ -int(MAX_DISPLAY_PV_NAME_LENGTH / 2) + 2:] checkbox.setText(display_name) data_text = QLabel(parent=individual_curve_grpbx) data_text.setWordWrap(True) data_text.setObjectName(pv_name + "_lbl") data_text.setPalette(palette) checkbox.setChecked(True) checkbox.toggled.connect( partial(self.handle_curve_chkbox_toggled, checkbox)) if not self.chart.findCurve(pv_name).isVisible(): checkbox.setChecked(False) modify_curve_btn = QPushButton("Modify...", parent=individual_curve_grpbx) modify_curve_btn.setObjectName(pv_name + "_btn_modify") modify_curve_btn.setMaximumWidth(80) modify_curve_btn.clicked.connect( partial(self.display_curve_settings_dialog, pv_name)) focus_curve_btn = QPushButton("Focus", parent=individual_curve_grpbx) focus_curve_btn.setObjectName(pv_name + "_btn_focus") focus_curve_btn.setMaximumWidth(80) focus_curve_btn.clicked.connect(partial(self.focus_curve, pv_name)) clear_curve_btn = QPushButton("Clear", parent=individual_curve_grpbx) clear_curve_btn.setObjectName(pv_name + "_btn_clear") clear_curve_btn.setMaximumWidth(80) clear_curve_btn.clicked.connect(partial(self.clear_curve, pv_name)) # annotate_curve_btn = QPushButton("Annotate...", # parent=individual_curve_grpbx) # annotate_curve_btn.setObjectName(pv_name+"_btn_ann") # annotate_curve_btn.setMaximumWidth(80) # annotate_curve_btn.clicked.connect( # partial(self.annotate_curve, pv_name)) remove_curve_btn = QPushButton("Remove", parent=individual_curve_grpbx) remove_curve_btn.setObjectName(pv_name + "_btn_remove") remove_curve_btn.setMaximumWidth(80) remove_curve_btn.clicked.connect(partial(self.remove_curve, pv_name)) curve_btn_layout = QHBoxLayout() curve_btn_layout.setSpacing(5) curve_btn_layout.addWidget(modify_curve_btn) curve_btn_layout.addWidget(focus_curve_btn) curve_btn_layout.addWidget(clear_curve_btn) # curve_btn_layout.addWidget(annotate_curve_btn) curve_btn_layout.addWidget(remove_curve_btn) individual_curve_layout.addWidget(checkbox) individual_curve_layout.addWidget(data_text) individual_curve_layout.addLayout(curve_btn_layout) self.curve_settings_layout.addWidget(individual_curve_grpbx) self.tab_panel.setCurrentIndex(0) def handle_curve_chkbox_toggled(self, checkbox): """ Handle a checkbox's checked and unchecked events. If a checkbox is checked, find the curve from the channel map. If found, re-draw the curve with its previous appearance settings. If a checkbox is unchecked, remove the curve from the chart, but keep the cached data in the channel map. Parameters ---------- checkbox : QCheckBox The current checkbox being toggled """ pv_name = self._get_full_pv_name(checkbox.text()) if checkbox.isChecked(): curve = self.channel_map.get(pv_name, None) if curve: curve.show() self.chart.addLegendItem(curve, pv_name, self.show_legend_chk.isChecked()) self.change_legend_font(self.legend_font) else: curve = self.chart.findCurve(pv_name) if curve: curve.hide() self.chart.removeLegendItem(pv_name) def display_curve_settings_dialog(self, pv_name): """ Bring up the Curve Settings dialog to modify the appearance of a curve. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ self.curve_settings_disp = CurveSettingsDisplay(self, pv_name) self.curve_settings_disp.show() def focus_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: self.chart.plotItem.setYRange(curve.minY, curve.maxY, padding=0) def clear_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: curve.initialize_buffer() def annotate_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: annot = TextItem( html= '<div style="text-align: center"><span style="color: #FFF;">This is the' '</span><br><span style="color: #FF0; font-size: 16pt;">PEAK</span></div>', anchor=(-0.3, 0.5), border='w', fill=(0, 0, 255, 100)) self.chart.annotateCurve(curve, annot) def remove_curve(self, pv_name): """ Remove a curve from the chart permanently. This will also clear the channel map cache from retaining the removed curve's appearance settings. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ curve = self.chart.findCurve(pv_name) if curve: try: self.app.remove_connection(curve.channel) except AttributeError: # these methods are not needed on future versions of pydm pass self.chart.removeYChannel(curve) del self.channel_map[pv_name] self.chart.removeLegendItem(pv_name) widget = self.findChild(QGroupBox, pv_name + "_grb") if widget: widget.deleteLater() if len(self.chart.getCurves()) < 1: self.enable_chart_control_buttons(False) self.show_legend_chk.setChecked(False) def handle_title_text_changed(self, new_text): self.chart.setPlotTitle(new_text) def handle_change_axis_settings_clicked(self): self.axis_settings_disp = AxisSettingsDisplay(self) self.axis_settings_disp.show() def handle_limit_time_span_checkbox_clicked(self, is_checked): self.chart_limit_time_span_lbl.setVisible(is_checked) self.chart_limit_time_span_hours_spin_box.setVisible(is_checked) self.chart_limit_time_span_minutes_spin_box.setVisible(is_checked) self.chart_limit_time_span_seconds_spin_box.setVisible(is_checked) self.chart_limit_time_span_activate_btn.setVisible(is_checked) self.chart_ring_buffer_size_lbl.setDisabled(is_checked) self.chart_ring_buffer_size_edt.setDisabled(is_checked) if not is_checked: self.chart_limit_time_span_chk.setText(self.limit_time_plan_text) def handle_time_span_changed(self): self.time_span_limit_hours = self.chart_limit_time_span_hours_spin_box.value( ) self.time_span_limit_minutes = self.chart_limit_time_span_minutes_spin_box.value( ) self.time_span_limit_seconds = self.chart_limit_time_span_seconds_spin_box.value( ) status = self.time_span_limit_hours > 0 or self.time_span_limit_minutes > 0 or self.time_span_limit_seconds > 0 self.chart_limit_time_span_activate_btn.setEnabled(status) def handle_chart_limit_time_span_activate_btn_clicked(self): timeout_milliseconds = (self.time_span_limit_hours * 3600 + self.time_span_limit_minutes * 60 + self.time_span_limit_seconds) * 1000 self.chart.setTimeSpan(timeout_milliseconds / 1000.0) self.chart_ring_buffer_size_edt.setText(str( self.chart.getBufferSize())) def handle_buffer_size_changed(self): try: new_buffer_size = int(self.chart_ring_buffer_size_edt.text()) if new_buffer_size and int(new_buffer_size) >= MINIMUM_BUFFER_SIZE: self.chart.setBufferSize(new_buffer_size) except ValueError: display_message_box(QMessageBox.Critical, "Invalid Values", "Only integer values are accepted.") def handle_redraw_rate_changed(self): self.chart.maxRedrawRate = self.chart_redraw_rate_spin.value() def handle_data_sampling_rate_changed(self): # The chart expects the value in milliseconds sampling_rate_seconds = 1.0 / self.chart_data_async_sampling_rate_spin.value( ) buffer_size = self.chart.getBufferSize() self.chart.setUpdateInterval(sampling_rate_seconds) if self.chart.getBufferSize() < buffer_size: self.chart.setBufferSize(buffer_size) self.chart_ring_buffer_size_edt.setText(str( self.chart.getBufferSize())) def handle_background_color_button_clicked(self): selected_color = QColorDialog.getColor() self.chart.setBackgroundColor(selected_color) self.background_color_btn.setStyleSheet("background-color: " + selected_color.name()) def handle_axis_color_button_clicked(self): selected_color = QColorDialog.getColor() self.chart.setAxisColor(selected_color) self.axis_color_btn.setStyleSheet("background-color: " + selected_color.name()) def handle_grid_opacity_slider_mouse_release(self): self.grid_alpha = float(self.grid_opacity_slr.value()) / 10.0 self.chart.setShowXGrid(self.show_x_grid_chk.isChecked(), self.grid_alpha) self.chart.setShowYGrid(self.show_y_grid_chk.isChecked(), self.grid_alpha) def handle_show_x_grid_checkbox_clicked(self, is_checked): self.chart.setShowXGrid(is_checked, self.grid_alpha) self.grid_opacity_lbl.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) self.grid_opacity_slr.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) def handle_show_y_grid_checkbox_clicked(self, is_checked): self.chart.setShowYGrid(is_checked, self.grid_alpha) self.grid_opacity_lbl.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) self.grid_opacity_slr.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) def handle_show_legend_checkbox_clicked(self, is_checked): self.chart.setShowLegend(is_checked) def handle_export_data_btn_clicked(self): self.chart_data_export_disp = ChartDataExportDisplay(self) self.chart_data_export_disp.show() def handle_import_data_btn_clicked(self): open_file_info = QFileDialog.getOpenFileName( self, caption="Open File", directory=os.path.expanduser('~'), filter=IMPORT_FILE_FORMAT) open_filename = open_file_info[0] if open_filename: try: importer = SettingsImporter(self) importer.import_settings(open_filename) except SettingsImporterException: display_message_box( QMessageBox.Critical, "Import Failure", "Cannot import the file '{0}'. Check the log for the error details." .format(open_filename)) logger.exception( "Cannot import the file '{0}'".format(open_filename)) def handle_sync_mode_radio_toggle(self, radio_btn): if radio_btn.isChecked(): if radio_btn.text() == "Synchronous": self.data_sampling_mode = SYNC_DATA_SAMPLING self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart.resetTimeSpan() self.chart_limit_time_span_chk.setChecked(False) self.chart_limit_time_span_chk.clicked.emit(False) self.chart_limit_time_span_chk.hide() self.chart.setUpdatesAsynchronously(False) elif radio_btn.text() == "Asynchronous": self.data_sampling_mode = ASYNC_DATA_SAMPLING self.chart_data_sampling_rate_lbl.show() self.chart_data_async_sampling_rate_spin.show() self.chart_limit_time_span_chk.show() self.chart.setUpdatesAsynchronously(True) def handle_zoom_in_btn_clicked(self, axis, is_zoom_in): scale_factor = 0.5 if not is_zoom_in: scale_factor += 1.0 if axis == "x": self.chart.getViewBox().scaleBy(x=scale_factor) elif axis == "y": self.chart.getViewBox().scaleBy(y=scale_factor) def handle_view_all_button_clicked(self): self.chart.plotItem.getViewBox().autoRange() def handle_pause_chart_btn_clicked(self): if self.chart.pausePlotting(): self.pause_chart_btn.setIcon(self.pause_icon) else: self.pause_chart_btn.setIcon(self.play_icon) def handle_reset_chart_btn_clicked(self): self.chart.getViewBox().setXRange(DEFAULT_X_MIN, 0) self.chart.resetAutoRangeY() @Slot() def handle_reset_chart_settings_btn_clicked(self): self.chart.setBackgroundColor(DEFAULT_CHART_BACKGROUND_COLOR) self.background_color_btn.setStyleSheet( "background-color: " + DEFAULT_CHART_BACKGROUND_COLOR.name()) self.chart.setAxisColor(DEFAULT_CHART_AXIS_COLOR) self.axis_color_btn.setStyleSheet("background-color: " + DEFAULT_CHART_AXIS_COLOR.name()) self.grid_opacity_slr.setValue(5) self.show_x_grid_chk.setChecked(False) self.show_x_grid_chk.clicked.emit(False) self.show_y_grid_chk.setChecked(False) self.show_y_grid_chk.clicked.emit(False) self.show_legend_chk.setChecked(False) self.chart.setShowXGrid(False) self.chart.setShowYGrid(False) self.chart.setShowLegend(False) @Slot() def handle_reset_data_settings_btn_clicked(self): self.chart_ring_buffer_size_edt.setText(str(DEFAULT_BUFFER_SIZE)) self.chart_redraw_rate_spin.setValue(DEFAULT_REDRAW_RATE_HZ) self.handle_redraw_rate_changed() self.chart_data_async_sampling_rate_spin.setValue( DEFAULT_DATA_SAMPLING_RATE_HZ) self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart_sync_mode_async_radio.setChecked(True) self.chart_sync_mode_async_radio.toggled.emit(True) self.chart_limit_time_span_chk.setChecked(False) self.chart_limit_time_span_chk.setText(self.limit_time_plan_text) self.chart_limit_time_span_chk.clicked.emit(False) self.chart.setUpdatesAsynchronously(True) self.chart.resetTimeSpan() self.chart.resetUpdateInterval() self.chart.setBufferSize(DEFAULT_BUFFER_SIZE) def enable_chart_control_buttons(self, enabled=True): self.zoom_in_x_btn.setEnabled(enabled) self.zoom_out_x_btn.setEnabled(enabled) self.zoom_in_y_btn.setEnabled(enabled) self.zoom_out_y_btn.setEnabled(enabled) self.view_all_btn.setEnabled(enabled) self.reset_chart_btn.setEnabled(enabled) self.pause_chart_btn.setIcon(self.pause_icon) self.pause_chart_btn.setEnabled(enabled) self.export_data_btn.setEnabled(enabled) def _get_full_pv_name(self, pv_name): """ Append the protocol to the PV Name. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ if pv_name and "://" not in pv_name: pv_name = ''.join([self.pv_protocol_cmb.currentText(), pv_name]) return pv_name def handle_update_datetime_timer_timeout(self): current_label = self.chart.getBottomAxisLabel() new_label = "Current Time: " + TimeChartDisplay.get_current_datetime() if X_AXIS_LABEL_SEPARATOR in current_label: current_label = current_label[current_label. find(X_AXIS_LABEL_SEPARATOR) + len(X_AXIS_LABEL_SEPARATOR):] new_label += X_AXIS_LABEL_SEPARATOR + current_label self.chart.setLabel("bottom", text=new_label) def update_curve_data(self, curve): """ Determine if the PV is active. If not, disable the related PV controls. If the PV is active, update the PV controls' states. Parameters ---------- curve : PlotItem A PlotItem, i.e. a plot, to draw on the chart. """ pv_name = curve.address min_y = curve.minY if curve.minY else 0 max_y = curve.maxY if curve.maxY else 0 current_y = curve.data_buffer[1, -1] grb = self.findChild(QGroupBox, pv_name + "_grb") lbl = grb.findChild(QLabel, pv_name + "_lbl") lbl.setText("(yMin = {0:.3f}, yMax = {1:.3f}) y = {2:.3f}".format( min_y, max_y, current_y)) chb = grb.findChild(QCheckBox, pv_name + "_chb") connected = curve.connected if connected and chb.isEnabled(): return chb.setEnabled(connected) btn_modify = grb.findChild(QPushButton, pv_name + "_btn_modify") btn_modify.setEnabled(connected) btn_focus = grb.findChild(QPushButton, pv_name + "_btn_focus") btn_focus.setEnabled(connected) # btn_ann = grb.findChild(QPushButton, pv_name + "_btn_ann") # btn_ann.setEnabled(connected) @staticmethod def get_current_datetime(): current_date = datetime.datetime.now().strftime("%b %d, %Y") current_time = datetime.datetime.now().strftime("%H:%M:%S") current_datetime = current_time + ' (' + current_date + ')' return current_datetime @property def gridAlpha(self): return self.grid_alpha
class WndLoadQuantitativeCalibration(SecondaryWindow): signal_quantitative_calibration_changed = Signal() def __init__(self, *, gpc, gui_vars): super().__init__() # Global processing classes self.gpc = gpc # Global GUI variables (used for control of GUI state) self.gui_vars = gui_vars self.initialize() def initialize(self): self.table_header_display_names = False self.setWindowTitle("PyXRF: Load Quantitative Calibration") self.setMinimumWidth(750) self.setMinimumHeight(400) self.resize(750, 600) self.pb_load_calib = QPushButton("Load Calibration ...") self.pb_load_calib.clicked.connect(self.pb_load_calib_clicked) self._changes_exist = False self._auto_update = True self.cb_auto_update = QCheckBox("Auto") self.cb_auto_update.setCheckState( Qt.Checked if self._auto_update else Qt.Unchecked) self.cb_auto_update.stateChanged.connect( self.cb_auto_update_state_changed) self.pb_update_plots = QPushButton("Update Plots") self.pb_update_plots.clicked.connect(self.pb_update_plots_clicked) self.grp_current_scan = QGroupBox( "Parameters of Currently Processed Scan") self._distance_to_sample = 0.0 self.le_distance_to_sample = LineEditExtended() le_dist_validator = QDoubleValidator() le_dist_validator.setBottom(0) self.le_distance_to_sample.setValidator(le_dist_validator) self._set_distance_to_sample() self.le_distance_to_sample.editingFinished.connect( self.le_distance_to_sample_editing_finished) self.le_distance_to_sample.focusOut.connect( self.le_distance_to_sample_focus_out) hbox = QHBoxLayout() hbox.addWidget(QLabel("Distance-to-sample:")) hbox.addWidget(self.le_distance_to_sample) hbox.addStretch(1) self.grp_current_scan.setLayout(hbox) self.eline_rb_exclusive = [ ] # Holds the list of groups of exclusive radio buttons self._setup_tab_widget() vbox = QVBoxLayout() hbox = QHBoxLayout() hbox.addWidget(self.pb_load_calib) hbox.addStretch(1) hbox.addWidget(self.cb_auto_update) hbox.addWidget(self.pb_update_plots) vbox.addLayout(hbox) vbox.addWidget(self.tab_widget) vbox.addWidget(self.grp_current_scan) self.setLayout(vbox) # Display data self.update_all_data() self._set_tooltips() def _setup_tab_widget(self): self.tab_widget = QTabWidget() self.loaded_standards = QWidget() # self.display_loaded_standards() self.scroll = QScrollArea() self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scroll.setWidget(self.loaded_standards) self.tab_widget.addTab(self.scroll, "Loaded Standards") self.combo_set_table_header = QComboBox() self.combo_set_table_header.addItems( ["Standard Serial #", "Standard Name"]) self.combo_set_table_header.currentIndexChanged.connect( self.combo_set_table_header_index_changed) vbox = QVBoxLayout() vbox.addSpacing(5) hbox = QHBoxLayout() hbox.addWidget(QLabel("Display in table header:")) hbox.addWidget(self.combo_set_table_header) hbox.addStretch(1) vbox.addLayout(hbox) self.table = QTableWidget() self.table.verticalHeader().hide() self.table.setSelectionMode(QTableWidget.NoSelection) self.table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeToContents) self.table.horizontalHeader().setMinimumSectionSize(150) vbox.addWidget(self.table) self.table.setStyleSheet("QTableWidget::item{color: black;}") frame = QFrame() vbox.setContentsMargins(0, 0, 0, 0) frame.setLayout(vbox) self.tab_widget.addTab(frame, "Selected Emission Lines") def display_loaded_standards(self): calib_data = self.gpc.get_quant_calibration_data() calib_settings = self.gpc.get_quant_calibration_settings() # Create the new widget (this deletes the old widget) self.loaded_standards = QWidget() self.loaded_standards.setMinimumWidth(700) # Also delete references to all components self.frames_calib_data = [] self.pbs_view = [] self.pbs_remove = [] # All 'View' buttons are added to the group in order to be connected to the same slot self.group_view = QButtonGroup() self.group_view.setExclusive(False) self.group_view.buttonClicked.connect(self.pb_view_clicked) # The same for the 'Remove' buttons self.group_remove = QButtonGroup() self.group_remove.setExclusive(False) self.group_remove.buttonClicked.connect(self.pb_remove_clicked) vbox = QVBoxLayout() class _LabelBlack(QLabel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setStyleSheet("color: black") for cdata, csettings in zip(calib_data, calib_settings): frame = QFrame() frame.setFrameStyle(QFrame.StyledPanel) frame.setStyleSheet( get_background_css((200, 255, 200), widget="QFrame")) _vbox = QVBoxLayout() name = cdata["name"] # Standard name (can be arbitrary string # If name is long, then print it in a separate line _name_is_long = len(name) > 30 pb_view = QPushButton("View ...") self.group_view.addButton(pb_view) pb_remove = QPushButton("Remove") self.group_remove.addButton(pb_remove) # Row 1: serial, name serial = cdata["serial"] _hbox = QHBoxLayout() _hbox.addWidget(_LabelBlack(f"<b>Standard</b> #{serial}")) if not _name_is_long: _hbox.addWidget(_LabelBlack(f"'{name}'")) _hbox.addStretch(1) _hbox.addWidget(pb_view) _hbox.addWidget(pb_remove) _vbox.addLayout(_hbox) # Optional row if _name_is_long: # Wrap name if it is extemely long name = textwrap.fill(name, width=80) _hbox = QHBoxLayout() _hbox.addWidget(_LabelBlack("<b>Name:</b> "), 0, Qt.AlignTop) _hbox.addWidget(_LabelBlack(name), 0, Qt.AlignTop) _hbox.addStretch(1) _vbox.addLayout(_hbox) # Row 2: description description = textwrap.fill(cdata["description"], width=80) _hbox = QHBoxLayout() _hbox.addWidget(_LabelBlack("<b>Description:</b>"), 0, Qt.AlignTop) _hbox.addWidget(_LabelBlack(f"{description}"), 0, Qt.AlignTop) _hbox.addStretch(1) _vbox.addLayout(_hbox) # Row 3: incident_energy = cdata["incident_energy"] scaler = cdata["scaler_name"] detector_channel = cdata["detector_channel"] distance_to_sample = cdata["distance_to_sample"] _hbox = QHBoxLayout() _hbox.addWidget( _LabelBlack(f"<b>Incident energy, keV:</b> {incident_energy}")) _hbox.addWidget(_LabelBlack(f" <b>Scaler:</b> {scaler}")) _hbox.addWidget( _LabelBlack(f" <b>Detector channel:</b> {detector_channel}")) _hbox.addWidget( _LabelBlack( f" <b>Distance-to-sample:</b> {distance_to_sample}")) _hbox.addStretch(1) _vbox.addLayout(_hbox) # Row 4: file name fln = textwrap.fill(csettings["file_path"], width=80) _hbox = QHBoxLayout() _hbox.addWidget(_LabelBlack("<b>Source file:</b>"), 0, Qt.AlignTop) _hbox.addWidget(_LabelBlack(fln), 0, Qt.AlignTop) _hbox.addStretch(1) _vbox.addLayout(_hbox) frame.setLayout(_vbox) # Now the group box is added to the upper level layout vbox.addWidget(frame) vbox.addSpacing(5) self.frames_calib_data.append(frame) self.pbs_view.append(pb_view) self.pbs_remove.append(pb_remove) # Add the layout to the widget self.loaded_standards.setLayout(vbox) # ... and put the widget inside the scroll area. This will update the # contents of the scroll area. self.scroll.setWidget(self.loaded_standards) def display_table_header(self): calib_data = self.gpc.get_quant_calibration_data() header_by_name = self.table_header_display_names tbl_labels = ["Lines"] for n, cdata in enumerate(calib_data): if header_by_name: txt = cdata["name"] else: txt = cdata["serial"] txt = textwrap.fill(txt, width=20) tbl_labels.append(txt) self.table.setHorizontalHeaderLabels(tbl_labels) def display_standard_selection_table(self): calib_data = self.gpc.get_quant_calibration_data() self._quant_file_paths = self.gpc.get_quant_calibration_file_path_list( ) brightness = 220 table_colors = [(255, brightness, brightness), (brightness, 255, brightness)] # Disconnect all radio button signals before clearing the table for bgroup in self.eline_rb_exclusive: bgroup.buttonToggled.disconnect(self.rb_selection_toggled) # This list will hold radio button groups for horizontal rows # Those are exclusive groups. They are not going to be # used directly, but they must be kept alive in order # for the radiobuttons to work properly. Most of the groups # will contain only 1 radiobutton, which will always remain checked. self.eline_rb_exclusive = [] # The following list will contain the list of radio buttons for each # row. If there is no radiobutton in a position, then the element is # set to None. # N rows: the number of emission lines, N cols: the number of standards self.eline_rb_lists = [] self.table.clear() if not calib_data: self.table.setRowCount(0) self.table.setColumnCount(0) else: # Create the sorted list of available element lines line_set = set() for cdata in calib_data: ks = list(cdata["element_lines"].keys()) line_set.update(list(ks)) self.eline_list = list(line_set) self.eline_list.sort() for n in range(len(self.eline_list)): self.eline_rb_exclusive.append(QButtonGroup()) self.eline_rb_lists.append([None] * len(calib_data)) self.table.setColumnCount(len(calib_data) + 1) self.table.setRowCount(len(self.eline_list)) self.display_table_header() for n, eline in enumerate(self.eline_list): rgb = table_colors[n % 2] item = QTableWidgetItem(eline) item.setTextAlignment(Qt.AlignCenter) item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setBackground(QBrush(QColor(*rgb))) self.table.setItem(n, 0, item) for ns, cdata in enumerate(calib_data): q_file_path = self._quant_file_paths[ ns] # Used to identify standard if eline in cdata["element_lines"]: rb = QRadioButton() if self.gpc.get_quant_calibration_is_eline_selected( eline, q_file_path): rb.setChecked(True) rb.setStyleSheet("color: black") self.eline_rb_lists[n][ns] = rb # self.eline_rb_by_standard[ns].addButton(rb) self.eline_rb_exclusive[n].addButton(rb) item = QWidget() item_hbox = QHBoxLayout(item) item_hbox.addWidget(rb) item_hbox.setAlignment(Qt.AlignCenter) item_hbox.setContentsMargins(0, 0, 0, 0) item.setStyleSheet(get_background_css(rgb)) # Generate tooltip density = cdata["element_lines"][eline]["density"] fluorescence = cdata["element_lines"][eline][ "fluorescence"] ttip = f"Fluorescence (F): {fluorescence:12g}\nDensity (D): {density:12g}\n" # Avoid very small values of density (probably zero) if abs(density) > 1e-30: ttip += f"F/D: {fluorescence/density:12g}" item.setToolTip(ttip) self.table.setCellWidget(n, ns + 1, item) else: # There is no radio button, but we still need to fill the cell item = QTableWidgetItem("") item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setBackground(QBrush(QColor(*rgb))) self.table.setItem(n, ns + 1, item) # Now the table is set (specifically radio buttons). # So we can connect the button groups with the event processing function for bgroup in self.eline_rb_exclusive: bgroup.buttonToggled.connect(self.rb_selection_toggled) @Slot() def update_all_data(self): self.display_loaded_standards() self.display_standard_selection_table() self._set_distance_to_sample() def _set_distance_to_sample(self): """Set 'le_distance_to_sample` without updating maps""" distance_to_sample = self.gpc.get_quant_calibration_distance_to_sample( ) if distance_to_sample is None: distance_to_sample = 0.0 self._distance_to_sample = distance_to_sample self._set_le_distance_to_sample(distance_to_sample) def _set_tooltips(self): set_tooltip(self.pb_load_calib, "Load <b>calibration data</b> from JSON file.") set_tooltip( self.cb_auto_update, "Automatically <b>update the plots</b> when changes are made. " "If unchecked, then button <b>Update Plots</b> must be pressed " "to update the plots. Automatic update is often undesirable " "when large maps are displayed and multiple changes to parameters " "are made.", ) set_tooltip( self.pb_update_plots, "<b>Update plots</b> based on currently selected parameters.") set_tooltip( self.le_distance_to_sample, "Distance between <b>the sample and the detector</b>. The ratio between of the distances " "during calibration and measurement is used to scale computed concentrations. " "If distance-to-sample is 0 for calibration or measurement, then no scaling is performed.", ) set_tooltip( self.combo_set_table_header, "Use <b>Serial Number</b> or <b>Name</b> of the calibration standard in the header of the table", ) set_tooltip( self.table, "Use Radio Buttons to select the <b>source of calibration data</b> for each emission line. " "This feature is needed if multiple loaded calibration files have data on the same " "emission line.", ) def update_widget_state(self, condition=None): # Update the state of the menu bar state = not self.gui_vars["gui_state"]["running_computations"] self.setEnabled(state) # Hide the window if required by the program state state_xrf_map_exists = self.gui_vars["gui_state"][ "state_xrf_map_exists"] if not state_xrf_map_exists: self.hide() if condition == "tooltips": self._set_tooltips() def cb_auto_update_state_changed(self, state): self._auto_update = state self.pb_update_plots.setEnabled(not state) # If changes were made, apply the changes while switching to 'auto' mode if state and self._changes_exist: self._update_maps_auto() def pb_update_plots_clicked(self): self._update_maps() def pb_load_calib_clicked(self): current_dir = self.gpc.get_current_working_directory() file_name = QFileDialog.getOpenFileName( self, "Select File with Quantitative Calibration Data", current_dir, "JSON (*.json);; All (*)") file_name = file_name[0] if file_name: try: logger.debug( f"Loading quantitative calibration from file: '{file_name}'" ) self.gpc.load_quantitative_calibration_data(file_name) self.update_all_data() self._update_maps_auto() except Exception: msg = "The selected JSON file has incorrect format. Select a different file." msgbox = QMessageBox(QMessageBox.Critical, "Data Loading Error", msg, QMessageBox.Ok, parent=self) msgbox.exec() def pb_view_clicked(self, button): try: n_standard = self.pbs_view.index(button) calib_settings = self.gpc.get_quant_calibration_settings() file_path = calib_settings[n_standard]["file_path"] calib_preview = self.gpc.get_quant_calibration_text_preview( file_path) dlg = DialogViewCalibStandard(None, file_path=file_path, calib_preview=calib_preview) dlg.exec() except ValueError: logger.error( "'View' button was pressed, but not found in the list of buttons" ) def pb_remove_clicked(self, button): try: n_standard = self.pbs_remove.index(button) calib_settings = self.gpc.get_quant_calibration_settings() file_path = calib_settings[n_standard]["file_path"] self.gpc.quant_calibration_remove_entry(file_path) self.update_all_data() self._update_maps_auto() except ValueError: logger.error( "'Remove' button was pressed, but not found in the list of buttons" ) def rb_selection_toggled(self, button, checked): if checked: # Find the button in 2D list 'self.eline_rb_lists' button_found = False for nr, rb_list in enumerate(self.eline_rb_lists): try: nc = rb_list.index(button) button_found = True break except ValueError: pass if button_found: eline = self.eline_list[nr] n_standard = nc file_path = self._quant_file_paths[n_standard] self.gpc.set_quant_calibration_select_eline(eline, file_path) self._update_maps_auto() else: # This should never happen logger.error( "Selection radio button was pressed, but not found in the list" ) def combo_set_table_header_index_changed(self, index): self.table_header_display_names = bool(index) self.display_table_header() def le_distance_to_sample_editing_finished(self): distance_to_sample = float(self.le_distance_to_sample.text()) if distance_to_sample != self._distance_to_sample: self._distance_to_sample = distance_to_sample self.gpc.set_quant_calibration_distance_to_sample( distance_to_sample) self._update_maps_auto() def le_distance_to_sample_focus_out(self): try: float(self.le_distance_to_sample.text()) except ValueError: # If the text can not be interpreted to float, then replace the text with the old value self._set_le_distance_to_sample(self._distance_to_sample) def _set_le_distance_to_sample(self, distance_to_sample): self.le_distance_to_sample.setText(f"{distance_to_sample:.12g}") def _update_maps_auto(self): """Update maps only if 'auto' update is ON. Used as a 'filter' to prevent extra plot updates.""" self._changes_exist = True if self._auto_update: self._update_maps() def _update_maps(self): """Upload the selections (limit table) and update plot""" self._changes_exist = False self._redraw_maps() # Emit signal only after the maps are redrawn. This should change # ranges in the respective controls for the plots self.signal_quantitative_calibration_changed.emit() def _redraw_maps(self): # We don't emit any signals here, but we don't really need to. logger.debug("Redrawing RGB XRF Maps") self.gpc.compute_map_ranges() self.gpc.redraw_maps() self.gpc.compute_rgb_map_ranges() self.gpc.redraw_rgb_maps()
class ThumbnailScrollBar(QFrame): """ A widget that manages the display of the FigureThumbnails that are created when a figure is sent to the IPython console by the kernel and that controls what is displayed in the FigureViewer. """ _min_scrollbar_width = 100 # Signals sig_redirect_stdio_requested = Signal(bool) """ This signal is emitted to request the main application to redirect standard output/error when using Open/Save/Browse dialogs within widgets. Parameters ---------- redirect: bool Start redirect (True) or stop redirect (False). """ sig_save_dir_changed = Signal(str) """ This signal is emitted to inform that the current folder where images are saved has changed. Parameters ---------- save_dir: str The new path where images are saved. """ sig_context_menu_requested = Signal(QPoint, object) """ This signal is emitted to request a context menu. Parameters ---------- point: QPoint The QPoint in global coordinates where the menu was requested. """ def __init__(self, figure_viewer, parent=None, background_color=None): super().__init__(parent) self._thumbnails = [] self.background_color = background_color self.save_dir = getcwd_or_home() self.current_thumbnail = None self.set_figureviewer(figure_viewer) self.setup_gui() # Because the range of Qt scrollareas is not updated immediately # after a new item is added to it, setting the scrollbar's value # to its maximum value after adding a new item will scroll down to # the penultimate item instead of the last. # So to scroll programmatically to the latest item after it # is added to the scrollarea, we need to do it instead in a slot # connected to the scrollbar's rangeChanged signal. # See spyder-ide/spyder#10914 for more details. self._new_thumbnail_added = False self.scrollarea.verticalScrollBar().rangeChanged.connect( self._scroll_to_newest_item) def setup_gui(self): """Setup the main layout of the widget.""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.setup_scrollarea()) def setup_scrollarea(self): """Setup the scrollarea that will contain the FigureThumbnails.""" self.view = QWidget() self.scene = QGridLayout(self.view) self.scene.setContentsMargins(0, 0, 0, 0) # The vertical spacing between the thumbnails. # Note that we need to set this value explicitly or else the tests # are failing on macOS. See spyder-ide/spyder#11576. self.scene.setSpacing(5) self.scrollarea = QScrollArea() self.scrollarea.setWidget(self.view) self.scrollarea.setWidgetResizable(True) self.scrollarea.setFrameStyle(0) self.scrollarea.setViewportMargins(2, 2, 2, 2) self.scrollarea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scrollarea.setMinimumWidth(self._min_scrollbar_width) # Set the vertical scrollbar explicitly. # This is required to avoid a "RuntimeError: no access to protected # functions or signals for objects not created from Python" in Linux. self.scrollarea.setVerticalScrollBar(QScrollBar()) # Install an event filter on the scrollbar. self.scrollarea.installEventFilter(self) self.layout().setContentsMargins(0, 0, 0, 0) self.layout().setSpacing(0) return self.scrollarea def set_figureviewer(self, figure_viewer): """Set the namespace for the FigureViewer.""" self.figure_viewer = figure_viewer def eventFilter(self, widget, event): """ An event filter to trigger an update of the thumbnails size so that their width fit that of the scrollarea and to remap some key press events to mimick navigational behaviour of a Qt widget list. """ if event.type() == QEvent.KeyPress: key = event.key() if key == Qt.Key_Up: self.go_previous_thumbnail() return True elif key == Qt.Key_Down: self.go_next_thumbnail() return True if event.type() == QEvent.Resize: self._update_thumbnail_size() return super().eventFilter(widget, event) # ---- Save Figure def save_all_figures_as(self): """Save all the figures to a file.""" self.sig_redirect_stdio_requested.emit(False) dirname = getexistingdirectory(self, 'Save all figures', self.save_dir) self.sig_redirect_stdio_requested.emit(True) if dirname: self.sig_save_dir_changed.emit(dirname) return self.save_all_figures_todir(dirname) def save_all_figures_todir(self, dirname): """Save all figure in dirname.""" fignames = [] figname_root = ('Figure ' + datetime.datetime.now().strftime('%Y-%m-%d %H%M%S')) for thumbnail in self._thumbnails: fig = thumbnail.canvas.fig fmt = thumbnail.canvas.fmt fext = {'image/png': '.png', 'image/jpeg': '.jpg', 'image/svg+xml': '.svg'}[fmt] figname = get_unique_figname(dirname, figname_root, fext, start_at_zero=True) save_figure_tofile(fig, fmt, figname) fignames.append(figname) return fignames def save_current_figure_as(self): """Save the currently selected figure.""" if self.current_thumbnail is not None: self.save_figure_as(self.current_thumbnail.canvas.fig, self.current_thumbnail.canvas.fmt) def save_thumbnail_figure_as(self, thumbnail): """Save the currently selected figure.""" self.save_figure_as(thumbnail.canvas.fig, thumbnail.canvas.fmt) def save_figure_as(self, fig, fmt): """Save the figure to a file.""" fext, ffilt = { 'image/png': ('.png', 'PNG (*.png)'), 'image/jpeg': ('.jpg', 'JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)'), 'image/svg+xml': ('.svg', 'SVG (*.svg);;PNG (*.png)')}[fmt] figname = get_unique_figname( self.save_dir, 'Figure ' + datetime.datetime.now().strftime('%Y-%m-%d %H%M%S'), fext) self.sig_redirect_stdio_requested.emit(False) fname, fext = getsavefilename( parent=self.parent(), caption='Save Figure', basedir=figname, filters=ffilt, selectedfilter='', options=None) self.sig_redirect_stdio_requested.emit(True) if fname: self.sig_save_dir_changed.emit(osp.dirname(fname)) save_figure_tofile(fig, fmt, fname) # ---- Thumbails Handlers def _calculate_figure_canvas_width(self): """ Calculate the width the thumbnails need to have to fit the scrollarea. """ extra_padding = 10 if sys.platform == 'darwin' else 0 figure_canvas_width = ( self.scrollarea.width() - 2 * self.lineWidth() - self.scrollarea.viewportMargins().left() - self.scrollarea.viewportMargins().right() - extra_padding - self.scrollarea.verticalScrollBar().sizeHint().width() ) figure_canvas_width = figure_canvas_width - 6 return figure_canvas_width def _setup_thumbnail_size(self, thumbnail): """ Scale the thumbnail's canvas size so that it fits the thumbnail scrollbar's width. """ max_canvas_size = self._calculate_figure_canvas_width() thumbnail.scale_canvas_size(max_canvas_size) def _update_thumbnail_size(self): """ Update the thumbnails size so that their width fit that of the scrollarea. """ # NOTE: We hide temporarily the thumbnails to prevent a repaint of # each thumbnail as soon as their size is updated in the loop, which # causes some flickering of the thumbnail scrollbar resizing animation. # Once the size of all the thumbnails has been updated, we show them # back so that they are repainted all at once instead of one after the # other. This is just a trick to make the resizing animation of the # thumbnail scrollbar look smoother. self.view.hide() for thumbnail in self._thumbnails: self._setup_thumbnail_size(thumbnail) self.view.show() def show_context_menu(self, point, thumbnail): """ Emit global positioned point and thumbnail for context menu request. """ point = thumbnail.canvas.mapToGlobal(point) self.sig_context_menu_requested.emit(point, thumbnail) def add_thumbnail(self, fig, fmt): """ Add a new thumbnail to that thumbnail scrollbar. """ thumbnail = FigureThumbnail( parent=self, background_color=self.background_color) thumbnail.canvas.load_figure(fig, fmt) thumbnail.sig_canvas_clicked.connect(self.set_current_thumbnail) thumbnail.sig_remove_figure_requested.connect(self.remove_thumbnail) thumbnail.sig_save_figure_requested.connect(self.save_figure_as) thumbnail.sig_context_menu_requested.connect( lambda point: self.show_context_menu(point, thumbnail)) self._thumbnails.append(thumbnail) self._new_thumbnail_added = True self.scene.setRowStretch(self.scene.rowCount() - 1, 0) self.scene.addWidget(thumbnail, self.scene.rowCount() - 1, 0) self.scene.setRowStretch(self.scene.rowCount(), 100) self.set_current_thumbnail(thumbnail) thumbnail.show() self._setup_thumbnail_size(thumbnail) def remove_current_thumbnail(self): """Remove the currently selected thumbnail.""" if self.current_thumbnail is not None: self.remove_thumbnail(self.current_thumbnail) def remove_all_thumbnails(self): """Remove all thumbnails.""" for thumbnail in self._thumbnails: thumbnail.sig_canvas_clicked.disconnect() thumbnail.sig_remove_figure_requested.disconnect() thumbnail.sig_save_figure_requested.disconnect() self.layout().removeWidget(thumbnail) thumbnail.setParent(None) thumbnail.hide() thumbnail.close() self._thumbnails = [] self.current_thumbnail = None self.figure_viewer.figcanvas.clear_canvas() def remove_thumbnail(self, thumbnail): """Remove thumbnail.""" if thumbnail in self._thumbnails: index = self._thumbnails.index(thumbnail) # Disconnect signals try: thumbnail.sig_canvas_clicked.disconnect() thumbnail.sig_remove_figure_requested.disconnect() thumbnail.sig_save_figure_requested.disconnect() except TypeError: pass if thumbnail in self._thumbnails: self._thumbnails.remove(thumbnail) # Select a new thumbnail if any : if thumbnail == self.current_thumbnail: if len(self._thumbnails) > 0: self.set_current_index( min(index, len(self._thumbnails) - 1) ) else: self.figure_viewer.figcanvas.clear_canvas() self.current_thumbnail = None # Hide and close thumbnails self.layout().removeWidget(thumbnail) thumbnail.hide() thumbnail.close() # See: spyder-ide/spyder#12459 QTimer.singleShot( 150, lambda: self._remove_thumbnail_parent(thumbnail)) def _remove_thumbnail_parent(self, thumbnail): try: thumbnail.setParent(None) except RuntimeError: # Omit exception in case the thumbnail has been garbage-collected pass def set_current_index(self, index): """Set the currently selected thumbnail by its index.""" self.set_current_thumbnail(self._thumbnails[index]) def get_current_index(self): """Return the index of the currently selected thumbnail.""" try: return self._thumbnails.index(self.current_thumbnail) except ValueError: return -1 def set_current_thumbnail(self, thumbnail): """Set the currently selected thumbnail.""" if self.current_thumbnail == thumbnail: return if self.current_thumbnail is not None: self.current_thumbnail.highlight_canvas(False) self.current_thumbnail = thumbnail self.figure_viewer.load_figure( thumbnail.canvas.fig, thumbnail.canvas.fmt) self.current_thumbnail.highlight_canvas(True) def go_previous_thumbnail(self): """Select the thumbnail previous to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) - 1 index = index if index >= 0 else len(self._thumbnails) - 1 self.set_current_index(index) self.scroll_to_item(index) def go_next_thumbnail(self): """Select thumbnail next to the currently selected one.""" if self.current_thumbnail is not None: index = self._thumbnails.index(self.current_thumbnail) + 1 index = 0 if index >= len(self._thumbnails) else index self.set_current_index(index) self.scroll_to_item(index) def scroll_to_item(self, index): """Scroll to the selected item of ThumbnailScrollBar.""" spacing_between_items = self.scene.verticalSpacing() height_view = self.scrollarea.viewport().height() height_item = self.scene.itemAt(index).sizeHint().height() height_view_excluding_item = max(0, height_view - height_item) height_of_top_items = spacing_between_items for i in range(index): item = self.scene.itemAt(i) height_of_top_items += item.sizeHint().height() height_of_top_items += spacing_between_items pos_scroll = height_of_top_items - height_view_excluding_item // 2 vsb = self.scrollarea.verticalScrollBar() vsb.setValue(pos_scroll) def _scroll_to_newest_item(self, vsb_min, vsb_max): """ Scroll to the newest item added to the thumbnail scrollbar. Note that this method is called each time the rangeChanged signal is emitted by the scrollbar. """ if self._new_thumbnail_added: self._new_thumbnail_added = False self.scrollarea.verticalScrollBar().setValue(vsb_max) # ---- ScrollBar Handlers def go_up(self): """Scroll the scrollbar of the scrollarea up by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() - vsb.singleStep())) def go_down(self): """Scroll the scrollbar of the scrollarea down by a single step.""" vsb = self.scrollarea.verticalScrollBar() vsb.setValue(int(vsb.value() + vsb.singleStep()))