Source code for ccpn.ui.gui.widgets.TipOfTheDay

"""
Module Documentation here
"""
#=========================================================================================
# Licence, Reference and Credits
#=========================================================================================
__copyright__ = "Copyright (C) CCPN project (https://www.ccpn.ac.uk) 2014 - 2022"
__credits__ = ("Ed Brooksbank, Joanna Fox, Victoria A Higman, Luca Mureddu, Eliza Płoskoń",
               "Timothy J Ragan, Brian O Smith, Gary S Thompson & Geerten W Vuister")
__licence__ = ("CCPN licence. See https://ccpn.ac.uk/software/licensing/")
__reference__ = ("Skinner, S.P., Fogh, R.H., Boucher, W., Ragan, T.J., Mureddu, L.G., & Vuister, G.W.",
                 "CcpNmr AnalysisAssign: a flexible platform for integrated NMR analysis",
                 "J.Biomol.Nmr (2016), 66, 111-124, http://doi.org/10.1007/s10858-016-0060-y")
#=========================================================================================
# Last code modification
#=========================================================================================
__modifiedBy__ = "$modifiedBy: varioustoxins $"
__dateModified__ = "$dateModified: 2022-03-06 11:05:17 +0000 (Sun, March 06, 2022) $"
__version__ = "$Revision: 3.1.0 $"
#=========================================================================================
# Created
#=========================================================================================
__author__ = "$Author: varioustoxins $"
__date__ = "$Date: 2021-05-06 18:21:23 +0100 (Thu, May 6, 2021) $"
#=========================================================================================
# Start of code
#=========================================================================================

import hjson
import os
from pathlib import Path
import sys
from glob import glob
from operator import itemgetter
import random
from typing import Optional, List

from PyQt5 import QtGui
from PyQt5.QtCore import pyqtSignal, Qt, QRectF, QPointF
from PyQt5.QtGui import QPixmap, QBrush, QColor, QPainter
from PyQt5.QtWidgets import QApplication, QWizard, QWizardPage, QCheckBox, QPushButton, QLabel, QGridLayout, \
    QSizePolicy, QFrame, QTextBrowser, QGraphicsScene, QGraphicsView

from ccpn.framework.PathsAndUrls import tipOfTheDayConfig


HJSON_ERROR = hjson.HjsonDecodeError

RANDOM_TIP_BUTTON = QWizard.CustomButton1
DONT_SHOW_TIPS_BUTTON = QWizard.CustomButton2
HAVE_RANDOM_TIP_BUTTON = QWizard.HaveCustomButton1
HAVE_DONT_SHOW_TIPS_BUTTON = QWizard.HaveCustomButton2

MODE_TIP_OF_THE_DAY = 'TIP_OF_THE_DAY'
MODE_KEY_CONCEPTS = 'KEY_CONCEPTS'

TITLE = 'TITLE'
BUTTONS = 'BUTTONS'
DEFAULT = 'DEFAULT'
MIN_SIZE = 'MIN_SIZE'
LAYOUT = 'LAYOUT'
DIRECTORIES = 'DIRECTORIES'
IDENTIFIERS = 'IDENTIFIERS'
KEY_DEPTH = 'KEY_DEPTH'
EMPTY_TEXT = 'EMPTY_TEXT'
USE_DOTS = 'USE_DOTS'
HAS_DIVIDER = 'HAS_DIVIDER'
DIVIDER_COLOR = 'DIVIDER_COLOR'
DIVIDER_WIDTH = 'DIVIDER_WIDTH'

HEADER = 'header'
ORDER = 'order'
STYLES = 'styles'
TYPE = 'type'
PLACE_HOLDER = '_'
PATH = 'path'
PICTURE = 'picture'
SIMPLE_HTML = 'simple-html'
CONTENTS = 'contents'
COLOR = 'color'
MAX_ORDER = sys.maxsize

STYLE_FILE = 'style_file'

TIPS_SETUP = None
DEFAULT_CONFIG_PATH = 'tipConfig.hjson'


[docs]def loadTipsSetup(path: Path, tip_paths: Optional[List[Path]] = None): global TIPS_SETUP setup = hjson.loads(open(path, 'r').read()) if tip_paths is None: tip_paths = [QApplication.applicationDirPath()] for instance in setup.values(): if not isinstance(instance, dict): continue new_directories = [] if DIRECTORIES in instance: for path in instance[DIRECTORIES]: path = Path(path) if not path.is_absolute(): for tip_path in tip_paths: new_directories.append(str(Path(tip_path) / path)) else: new_directories.append(str(path)) instance[DIRECTORIES]= new_directories TIPS_SETUP = setup
def _load_default_setup_if_required(): global TIPS_SETUP if TIPS_SETUP is None: TIPS_SETUP = loadTipsSetup(DEFAULT_CONFIG_PATH) BUTTON_IDS = { 'Random' : RANDOM_TIP_BUTTON, 'Stretch' : QWizard.Stretch, 'Dont_show' : DONT_SHOW_TIPS_BUTTON, 'BackButton' : QWizard.BackButton, 'NextButton' : QWizard.NextButton, 'CancelButton': QWizard.CancelButton }
[docs]class Dots(QGraphicsView): def __init__(self, parent=None): super(Dots, self).__init__(parent=parent) self._dot_size = 10 self._pos = 0 self._length = 0 self.setFixedHeight(self._dot_size * 2) self.setScene(QGraphicsScene()) self._blackBrush = QBrush(QColor('black')) self._whiteBrush = QBrush(QColor('transparent')) self.setStyleSheet("border-width: 0px; border-style: solid;") self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform | QPainter.HighQualityAntialiasing) def _assure_children(self): error = self._length - len(self.items()) if error != 0: if error > 0: for i in range(error): ellipse = self.scene().addEllipse(QRectF(0, 0, self._dot_size, self._dot_size)) ellipse.setBrush(self._whiteBrush) center = self.sceneRect().center() items = self.items() dot_size_2 = self._dot_size / 2 for i in range(self._length): gaps = self._length / 2 dots = self._length total = dots + gaps width = total * self._dot_size width_2 = width / 2 x_center = center.x() - width_2 items[i].setPos(QPointF(x_center + i * self._dot_size * 2, center.y() - dot_size_2))
[docs] def setLength(self, length): self._length = length self._assure_children()
[docs] def setIndicatorPos(self, pos): self.items()[self._pos].setBrush(self._whiteBrush) self._pos = pos self.items()[self._pos].setBrush(self._blackBrush)
[docs] def resizeEvent(self, event: QtGui.QResizeEvent) -> None: our_scene = self.scene() if our_scene: width = event.size().width() height = event.size().height() self.scene().setSceneRect(0, 0, width, height)
[docs]class TipPage(QWizardPage): def __init__(self, data): super(TipPage, self).__init__() self._data = data self.setLayout(QGridLayout()) if self._data[USE_DOTS]: self._dots = Dots(parent=self) self._dots.show()
[docs] def setupPage(self): divider = "" if HAS_DIVIDER in self._data and self._data[HAS_DIVIDER]: divider_width = '1px' divider_color = '#a9a9a9' if DIVIDER_WIDTH in self._data: divider_width = self._data[DIVIDER_WIDTH] if DIVIDER_COLOR in self._data: divider_color = self._data[DIVIDER_COLOR] divider = f'border-top: {divider_width} solid {divider_color};' if COLOR in self._data: if COLOR in self._data: stylesheet = f"background-color: {self._data[COLOR]}; {divider}" self.parent().setStyleSheet(stylesheet) else: self.parent().setStyleSheet(f"{divider}") self.setStyleSheet("border-top: 0px solid transparent;") if self._data[USE_DOTS]: self._dots.setLength(len(self.wizard().pageIds())) self._dots.setIndicatorPos(self.wizard()._current_page_index())
[docs] def showEvent(self, a0: QtGui.QShowEvent): self.setupPage()
[docs] def resizeEvent(self, a0: QtGui.QResizeEvent) -> None: if self._data[USE_DOTS]: self._dots.setGeometry(0, self.height() - self._dots.height(), self.width(), self._dots.height()) self._dots.raise_()
[docs]class PictureTipPage(TipPage): def __init__(self, data): super(PictureTipPage, self).__init__(data) self._label = None self._picture = None @staticmethod def _load_from_path(path): return QPixmap(str(path))
[docs] def initializePage(self) -> None: super(PictureTipPage, self).initializePage() if not self._label: self._picture = self._load_from_path(self._data[CONTENTS][0]) self._label = QLabel(self) self._label.setPixmap(self._picture) self.layout().setContentsMargins(0, 0, 0, 0) self._label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.layout().addWidget(self._label, 0, 0)
[docs]class SimpleHtmlTipPage(TipPage): def __init__(self, data): super(SimpleHtmlTipPage, self).__init__(data) self._data = data self._text_browser = None def _load_from_path(self): text = None if len(self._data) > 0: with open(self._data[0], 'r') as file_h: text = file_h.read() if not text: text = f'file {self._data[0]} missing...' return text
[docs] def initializePage(self) -> None: super(SimpleHtmlTipPage, self).initializePage() if not self._text_browser: self._text_browser = QTextBrowser(self) self.layout().addWidget(self._text_browser, 0, 0) self._text_browser.setFrameStyle(QFrame.NoFrame) self._text_browser.setOpenExternalLinks(True) text = _read_text_from_field_or_file(self._data, CONTENTS) self._text_browser.setHtml(text) self.layout().setContentsMargins(0, 0, 0, 0)
TIP_PAGE_TYPE_TO_HANDLER = { SIMPLE_HTML: SimpleHtmlTipPage, PICTURE : PictureTipPage } # wizard pages: picture, html, movie (not implemented yet) def _read_text_from_field_or_file(data, field): result = "" if field in data: result = data[field] if isinstance(result, list): result = '\n'.join(result) try: if PATH in data: text_file_name = data[PATH].parents[0] / result if text_file_name.exists() and text_file_name.is_file(): with open(text_file_name, 'r') as file_h: result = file_h.read() except OSError: pass return result
[docs]class TipOfTheDayWindow(QWizard): dont_show = pyqtSignal(bool) seen_tips = pyqtSignal(list) def __init__(self, parent=None, seen_perma_ids=(), dont_show_tips=False, standalone=False, mode=MODE_TIP_OF_THE_DAY): _load_default_setup_if_required() super(TipOfTheDayWindow, self).__init__(parent=parent) self._page_list = [] self._id_path = {} self._visited_pages = set() self._id_page = {} self._page_path_to_perma_id = {} self._seen_perma_ids = set(seen_perma_ids) self._standalone = standalone self._mode = mode self.setWizardStyle(QWizard.ModernStyle) if not standalone: self._dont_show_tips_button = QCheckBox(PLACE_HOLDER) self._random_tip_button = QPushButton(PLACE_HOLDER) self.setOption(HAVE_RANDOM_TIP_BUTTON, True) self.setOption(HAVE_DONT_SHOW_TIPS_BUTTON, not standalone) self.setOption(QWizard.HaveNextButtonOnLastPage, True) if not standalone: self.setButton(DONT_SHOW_TIPS_BUTTON, self._dont_show_tips_button) if dont_show_tips: self._dont_show_tips_button.setCheckState(Qt.Checked) self._dont_show_tips_button.stateChanged.connect(self._dont_show_clicked) self.setButton(RANDOM_TIP_BUTTON, self._random_tip_button) self.button(BUTTON_IDS[TIPS_SETUP[DEFAULT]]).setAutoDefault(True) self.setOption(QWizard.NoCancelButton, False) for button, text in TIPS_SETUP[self._mode][BUTTONS].items(): button = BUTTON_IDS[button] self.setButtonText(button, text) layout = [BUTTON_IDS[button] for button in TIPS_SETUP[self._mode][LAYOUT]] if standalone: position = layout.index(DONT_SHOW_TIPS_BUTTON) if position >= 0: del layout[position] self.setButtonLayout(layout) self.setWindowTitle(TIPS_SETUP[self._mode][TITLE]) self.customButtonClicked.connect(self._button_clicked) self.currentIdChanged.connect(self._page_visited) self._load_pages() self.setTitleFormat(Qt.RichText) random.seed(1) self._centre_window()
[docs] def isStandalone(self): return self._standalone
def _dont_show_clicked(self, state): if state == Qt.Checked: self.dont_show.emit(True) else: self.dont_show.emit(False) def _current_page_index(self): return self._page_list.index(self.currentId()) @staticmethod def _load_tip_dict(path): result = None try: with open(path, 'r') as file_h: result = hjson.loads(file_h.read()) except (EnvironmentError, HJSON_ERROR) as e: print(f"WARNING: couldn't load tip file {path} because {e}") return result def _load_tip_file_data(self): files = [] for directory_name in TIPS_SETUP[self._mode][DIRECTORIES]: identifiers = [identifier.split('/') for identifier in TIPS_SETUP[self._mode][IDENTIFIERS]] for identifier_parts in identifiers: identifier_pattern = os.path.join(directory_name, *identifier_parts) tip_file_list = glob(identifier_pattern) file_parts = dict([(Path(file_path), file_path[len(directory_name) + 1:]) for file_path in tip_file_list]) file_parts = self._filter_dict_by_values(file_parts, self._seen_perma_ids) self._page_path_to_perma_id.update(file_parts) files.extend(file_parts.keys()) results = [] for file in files: tip_data = self._load_tip_dict(file) if ORDER not in tip_data: tip_data[ORDER] = MAX_ORDER for i, data_file in enumerate(tip_data[CONTENTS]): tip_data[CONTENTS][i] = str(file.parent / tip_data[CONTENTS][i]) tip_data[PATH] = file results.append(tip_data) results.sort(key=itemgetter(ORDER)) return results def _filter_dict_by_values(self, in_dict, filter_values): filtered_file_parts = {} for file_path, perma_id in in_dict.items(): if not perma_id in filter_values: filtered_file_parts[file_path] = perma_id return filtered_file_parts
[docs] def setup_page_from_tip_file(self, tip_file): tip_type = tip_file[TYPE] handler = TIP_PAGE_TYPE_TO_HANDLER[tip_type] copy_attributes = HAS_DIVIDER, DIVIDER_WIDTH, DIVIDER_COLOR for attribute in copy_attributes: if attribute in TIPS_SETUP[self._mode]: tip_file[attribute] = TIPS_SETUP[self._mode][attribute] if USE_DOTS not in tip_file: tip_file[USE_DOTS] = TIPS_SETUP[self._mode][USE_DOTS] if handler is not None: styles = {} if STYLES in tip_file: styles_data = tip_file[STYLES] if isinstance(styles_data, str): styles.update(self._load_styles_from_file(styles, tip_file)) elif isinstance(styles_data, dict): for style, value in styles_data.items(): if style == STYLE_FILE: styles.update(self._load_styles_from_file(value, tip_file)) else: styles[style] = value title = _read_text_from_field_or_file(tip_file, HEADER) try: title = title % styles except Exception as e: print(f'WARNING: failed to apply style because of {e}') page = handler(tip_file) page.setTitle(title) page.setMinimumSize(*TIPS_SETUP[self._mode][MIN_SIZE]) return page
@staticmethod def _load_styles_from_file(styles, tip_file): try: style_path = tip_file[PATH].parents[0] / styles with open(style_path, 'r') as file_h: styles = hjson.loads(file_h.read()) except IOError: pass if not isinstance(styles, dict): print(f"WARNING: styles field in file {tip_file[PATH]} is not a dict!") return styles def _load_pages(self): tip_files = self._load_tip_file_data() for tip_file in tip_files: page = self.setup_page_from_tip_file(tip_file) tip_id = self.addPage(page) self._id_path[tip_id] = tip_file self._page_list.append(tip_id) self._id_page[tip_id] = page if len(self._page_list) == 1: self._disable_random_tips() if len(self._page_list) == 0: if self._mode == MODE_KEY_CONCEPTS: header = "Note: the key concept viewer is not correctly configured..." else: header = "All Tips viewed: no more tips to show..." info_page = { HEADER : header, TYPE : "simple-html", CONTENTS: TIPS_SETUP[self._mode][EMPTY_TEXT], PATH : Path(os.path.realpath(__file__)), USE_DOTS: False } page = self.setup_page_from_tip_file(info_page) tip_id = self.addPage(page) self._page_list.append(tip_id)
[docs] def nextId(self) -> int: current_id = self.currentId() if len(self._page_list) and current_id in self._page_list: index = self._page_list.index(current_id) else: index = -1 result = -1 if index >= 0: if index < len(self._page_list) - 1: result = self._page_list[index + 1] else: result = -1 elif index == -1 and len(self._page_list) > 0: result = self._page_list[0] if not self._have_more_pages(): self._disable_random_tips() return result
def _have_more_pages(self): return len(self._visited_page_ids()) != len(self._page_list) - 1
[docs] def setMode(self, mode): self._mode = mode
# https://stackoverflow.com/questions/42324399/how-to-center-a-qdialog-in-qt def _centre_window(self): host = self.parentWidget() if host: hostRect = host.geometry() self.move(hostRect.center() - self.rect().center()) else: screenGeometry = QApplication.desktop().screenGeometry() x = int((screenGeometry.width() - self.width()) / 2) y = int((screenGeometry.height() - self.height()) / 2) self.move(x, y) def _visited_page_ids(self): return set(self._visited_pages) def _all_page_ids(self): return set(self.pageIds()) def _unvisited_page_ids(self): return self._all_page_ids() - self._visited_page_ids() def _button_clicked(self, button_clicked): if button_clicked == RANDOM_TIP_BUTTON: self._random_tip() def _random_tip(self): available_ids = list(self._unvisited_page_ids()) next_id = random.choice(available_ids) current_index = self._page_list.index(self.currentId()) self._page_list.remove(next_id) self._page_list.insert(current_index + 1, next_id) if len(available_ids) <= 1: self._disable_random_tips() self.next() def _disable_random_tips(self): self.button(RANDOM_TIP_BUTTON).setEnabled(False)
[docs] def done(self, result): super(TipOfTheDayWindow, self).done(result) self.seen_tips.emit(self._get_seen_tips_perma_ids())
def _get_seen_tips_perma_ids(self): seen_tips = self._get_seen_tips() perma_ids = [self._page_path_to_perma_id[tip_path] for tip_path in seen_tips] return perma_ids def _get_seen_tips(self): seen_tips = [] for page_id in self._visited_pages: if page_id in self._id_path: seen_tips.append(self._id_path[page_id][PATH]) return seen_tips def _page_visited(self, page_id): self.adjustSize() if page_id != -1: self._visited_pages.add(page_id) self.seen_tips.emit(self._get_seen_tips_perma_ids())
[docs] def showEvent(self, event: QtGui.QShowEvent) -> None: self.adjustSize() self._centre_window() super(TipOfTheDayWindow, self).showEvent(event)
if __name__ == '__main__': app = QApplication(sys.argv) # wizard = TipOfTheDayWindow(mode=MODE_KEY_CONCEPTS) wizard = TipOfTheDayWindow(mode=MODE_TIP_OF_THE_DAY) wizard.show() wizard.exec_() sys.exit(app.exec())