예제 #1
0
class DownloadPostProcess(QObject):
    bytes_processed = Signal(int)
    started = Signal()

    def __init__(self):
        super(DownloadPostProcess, self).__init__(None)

        self.file_watcher = QFileSystemWatcher()
        self.file_watcher.fileChanged.connect(self.read_bytes)

        self.logger = create_logger(__name__)

    @Slot(str)
    def track(self, url: str) -> None:
        track_success = self.file_watcher.addPath(url)
        self.logger.info("Track {file} success={success}".format(
            file=url, success=track_success))
        self.started.emit()

    @Slot(str)
    def read_bytes(self, path: str) -> None:
        bytes = QFileInfo(path).size()
        self.bytes_processed.emit(bytes)
        self.logger.debug("Read {bytes} bytes".format(bytes=bytes))
예제 #2
0
class DataConnection(ProjectItem):
    def __init__(self,
                 toolbox,
                 project,
                 logger,
                 name,
                 description,
                 x,
                 y,
                 references=None):
        """Data Connection class.

        Args:
            toolbox (ToolboxUI): QMainWindow instance
            project (SpineToolboxProject): the project this item belongs to
            logger (LoggerInterface): a logger instance
            name (str): Object name
            description (str): Object description
            x (float): Initial X coordinate of item icon
            y (float): Initial Y coordinate of item icon
            references (list): a list of file paths
        """
        super().__init__(name, description, x, y, project, logger)
        self._toolbox = toolbox
        self.reference_model = QStandardItemModel()  # References to files
        self.data_model = QStandardItemModel(
        )  # Paths of project internal files. These are found in DC data directory
        self.datapackage_icon = QIcon(QPixmap(":/icons/datapkg.png"))
        self.data_dir_watcher = None
        # Populate references model
        if references is None:
            references = list()
        # Convert relative paths to absolute
        self.references = [
            deserialize_path(r, self._project.project_dir) for r in references
        ]
        self.populate_reference_list(self.references)
        # Populate data (files) model
        data_files = self.data_files()
        self.populate_data_list(data_files)
        self.spine_datapackage_form = None

    def set_up(self):
        self.data_dir_watcher = QFileSystemWatcher(self)
        if os.path.isdir(self.data_dir):
            self.data_dir_watcher.addPath(self.data_dir)
        self.data_dir_watcher.directoryChanged.connect(self.refresh)

    @staticmethod
    def item_type():
        """See base class."""
        return ItemInfo.item_type()

    @staticmethod
    def item_category():
        """See base class."""
        return ItemInfo.item_category()

    def execution_item(self):
        """Creates DataConnection's execution counterpart."""
        data_files = [
            os.path.join(self.data_dir, f) for f in self.data_files()
        ]
        return ExecutableItem(self.name, self.file_references(), data_files,
                              self._logger)

    def make_signal_handler_dict(self):
        """Returns a dictionary of all shared signals and their handlers.
        This is to enable simpler connecting and disconnecting."""
        s = super().make_signal_handler_dict()
        # pylint: disable=unnecessary-lambda
        s[self._properties_ui.toolButton_dc_open_dir.
          clicked] = lambda checked=False: self.open_directory()
        s[self._properties_ui.toolButton_plus.clicked] = self.add_references
        s[self._properties_ui.toolButton_minus.
          clicked] = self.remove_references
        s[self._properties_ui.toolButton_add.clicked] = self.copy_to_project
        s[self._properties_ui.pushButton_datapackage.
          clicked] = self.show_spine_datapackage_form
        s[self._properties_ui.treeView_dc_references.
          doubleClicked] = self.open_reference
        s[self._properties_ui.treeView_dc_data.
          doubleClicked] = self.open_data_file
        s[self._properties_ui.treeView_dc_references.
          files_dropped] = self.add_files_to_references
        s[self._properties_ui.treeView_dc_data.
          files_dropped] = self.add_files_to_data_dir
        s[self.get_icon().
          files_dropped_on_icon] = self.receive_files_dropped_on_icon
        s[self._properties_ui.treeView_dc_references.
          del_key_pressed] = lambda: self.remove_references()
        s[self._properties_ui.treeView_dc_data.
          del_key_pressed] = lambda: self.remove_files()
        return s

    def restore_selections(self):
        """Restore selections into shared widgets when this project item is selected."""
        self._properties_ui.label_dc_name.setText(self.name)
        self._properties_ui.treeView_dc_references.setModel(
            self.reference_model)
        self._properties_ui.treeView_dc_data.setModel(self.data_model)

    @Slot("QVariant")
    def add_files_to_references(self, paths):
        """Add multiple file paths to reference list.

        Args:
            paths (list): A list of paths to files
        """
        repeated_paths = []
        new_paths = []
        for path in paths:
            if any(os.path.samefile(path, ref) for ref in self.references):
                repeated_paths.append(path)
            else:
                new_paths.append(path)
        repeated_paths = ", ".join(repeated_paths)
        if repeated_paths:
            self._logger.msg_warning.emit(
                f"Reference to file(s) <b>{repeated_paths}</b> already available"
            )
        if new_paths:
            self._toolbox.undo_stack.push(
                AddDCReferencesCommand(self, new_paths))

    def do_add_files_to_references(self, paths):
        abspaths = [os.path.abspath(path) for path in paths]
        self.references.extend(abspaths)
        self.populate_reference_list(self.references)

    @Slot("QGraphicsItem", list)
    def receive_files_dropped_on_icon(self, icon, file_paths):
        """Called when files are dropped onto a data connection graphics item.
        If the item is this Data Connection's graphics item, add the files to data."""
        if icon == self.get_icon():
            self.add_files_to_data_dir(file_paths)

    @Slot("QVariant")
    def add_files_to_data_dir(self, file_paths):
        """Add files to data directory"""
        for file_path in file_paths:
            filename = os.path.split(file_path)[1]
            self._logger.msg.emit(
                f"Copying file <b>{filename}</b> to <b>{self.name}</b>")
            try:
                shutil.copy(file_path, self.data_dir)
            except OSError:
                self._logger.msg_error.emit("[OSError] Copying failed")
                return

    @Slot(bool)
    def add_references(self, checked=False):
        """Let user select references to files for this data connection."""
        # noinspection PyCallByClass, PyTypeChecker, PyArgumentList
        answer = QFileDialog.getOpenFileNames(self._toolbox,
                                              "Add file references",
                                              self._project.project_dir, "*.*")
        file_paths = answer[0]
        if not file_paths:  # Cancel button clicked
            return
        self.add_files_to_references(file_paths)

    @Slot(bool)
    def remove_references(self, checked=False):
        """Remove selected references from reference list.
        Do not remove anything if there are no references selected.
        """
        indexes = self._properties_ui.treeView_dc_references.selectedIndexes()
        if not indexes:  # Nothing selected
            self._logger.msg.emit("Please select references to remove")
            return
        references = [ind.data(Qt.DisplayRole) for ind in indexes]
        self._toolbox.undo_stack.push(
            RemoveDCReferencesCommand(self, references))
        self._logger.msg.emit("Selected references removed")

    def do_remove_references(self, references):
        self.references = [
            r for r in self.references
            if not any(os.path.samefile(r, ref) for ref in references)
        ]
        self.populate_reference_list(self.references)

    @Slot(bool)
    def copy_to_project(self, checked=False):
        """Copy selected file references to this Data Connection's data directory."""
        selected_indexes = self._properties_ui.treeView_dc_references.selectedIndexes(
        )
        if not selected_indexes:
            self._logger.msg_warning.emit("No files to copy")
            return
        for index in selected_indexes:
            file_path = self.reference_model.itemFromIndex(index).data(
                Qt.DisplayRole)
            if not os.path.exists(file_path):
                self._logger.msg_error.emit(
                    f"File <b>{file_path}</b> does not exist")
                continue
            filename = os.path.split(file_path)[1]
            self._logger.msg.emit(
                f"Copying file <b>{filename}</b> to Data Connection <b>{self.name}</b>"
            )
            try:
                shutil.copy(file_path, self.data_dir)
            except OSError:
                self._logger.msg_error.emit("[OSError] Copying failed")
                continue

    @Slot("QModelIndex")
    def open_reference(self, index):
        """Open reference in default program."""
        if not index:
            return
        if not index.isValid():
            logging.error("Index not valid")
            return
        reference = self.file_references()[index.row()]
        url = "file:///" + reference
        # noinspection PyTypeChecker, PyCallByClass, PyArgumentList
        res = open_url(url)
        if not res:
            self._logger.msg_error.emit(
                f"Failed to open reference:<b>{reference}</b>")

    @Slot("QModelIndex")
    def open_data_file(self, index):
        """Open data file in default program."""
        if not index:
            return
        if not index.isValid():
            logging.error("Index not valid")
            return
        data_file = self.data_files()[index.row()]
        url = "file:///" + os.path.join(self.data_dir, data_file)
        # noinspection PyTypeChecker, PyCallByClass, PyArgumentList
        res = open_url(url)
        if not res:
            self._logger.msg_error.emit(
                f"Opening file <b>{data_file}</b> failed")

    @busy_effect
    def show_spine_datapackage_form(self):
        """Show spine_datapackage_form widget."""
        if self.spine_datapackage_form:
            if self.spine_datapackage_form.windowState() & Qt.WindowMinimized:
                # Remove minimized status and restore window with the previous state (maximized/normal state)
                self.spine_datapackage_form.setWindowState(
                    self.spine_datapackage_form.windowState()
                    & ~Qt.WindowMinimized | Qt.WindowActive)
                self.spine_datapackage_form.activateWindow()
            else:
                self.spine_datapackage_form.raise_()
            return
        self.spine_datapackage_form = SpineDatapackageWidget(self)
        self.spine_datapackage_form.destroyed.connect(
            self.datapackage_form_destroyed)
        self.spine_datapackage_form.show()

    @Slot()
    def datapackage_form_destroyed(self):
        """Notify a connection that datapackage form has been destroyed."""
        self.spine_datapackage_form = None

    def make_new_file(self):
        """Create a new blank file to this Data Connections data directory."""
        msg = "File name"
        # noinspection PyCallByClass, PyTypeChecker, PyArgumentList
        answer = QInputDialog.getText(self._toolbox,
                                      "Create new file",
                                      msg,
                                      flags=Qt.WindowTitleHint
                                      | Qt.WindowCloseButtonHint)
        file_name = answer[0]
        if not file_name.strip():
            return
        # Check that file name has no invalid chars
        if any(True for x in file_name if x in INVALID_FILENAME_CHARS):
            msg = f"File name <b>{file_name}</b> contains invalid characters."
            self._logger.information_box.emit("Creating file failed", msg)
            return
        file_path = os.path.join(self.data_dir, file_name)
        if os.path.exists(file_path):
            msg = f"File <b>{file_name}</b> already exists."
            self._logger.information_box.emit("Creating file failed", msg)
            return
        try:
            with open(file_path, "w"):
                self._logger.msg.emit(
                    f"File <b>{file_name}</b> created to Data Connection <b>{self.name}</b>"
                )
        except OSError:
            msg = "Please check directory permissions."
            self._logger.information_box.emit("Creating file failed", msg)
        return

    def remove_files(self):
        """Remove selected files from data directory."""
        indexes = self._properties_ui.treeView_dc_data.selectedIndexes()
        if not indexes:  # Nothing selected
            self._logger.msg.emit("Please select files to remove")
            return
        file_list = list()
        for index in indexes:
            file_at_index = self.data_model.itemFromIndex(index).data(
                Qt.DisplayRole)
            file_list.append(file_at_index)
        files = "\n".join(file_list)
        msg = (
            "The following files will be removed permanently from the project\n\n"
            "{0}\n\n"
            "Are you sure?".format(files))
        title = "Remove {0} File(s)".format(len(file_list))
        message_box = QMessageBox(QMessageBox.Question,
                                  title,
                                  msg,
                                  QMessageBox.Ok | QMessageBox.Cancel,
                                  parent=self._toolbox)
        message_box.button(QMessageBox.Ok).setText("Remove Files")
        answer = message_box.exec_()
        if answer == QMessageBox.Cancel:
            return
        for filename in file_list:
            path_to_remove = os.path.join(self.data_dir, filename)
            try:
                os.remove(path_to_remove)
                self._logger.msg.emit(f"File <b>{path_to_remove}</b> removed")
            except OSError:
                self._logger.msg_error.emit(
                    f"Removing file {path_to_remove} failed.\nCheck permissions."
                )
        return

    def file_references(self):
        """Returns a list of paths to files that are in this item as references."""
        return self.references

    def data_files(self):
        """Returns a list of files that are in the data directory."""
        if not os.path.isdir(self.data_dir):
            return []
        files = list()
        with os.scandir(self.data_dir) as scan_iterator:
            for entry in scan_iterator:
                if entry.is_file():
                    files.append(entry.path)
        return files

    @Slot("QString")
    def refresh(self, _=None):
        """Refresh data files in Data Connection Properties.
        NOTE: Might lead to performance issues."""
        d = self.data_files()
        self.populate_data_list(d)

    def populate_reference_list(self, items, emit_item_changed=True):
        """List file references in QTreeView.
        If items is None or empty list, model is cleared.
        """
        self.reference_model.clear()
        self.reference_model.setHorizontalHeaderItem(
            0, QStandardItem("References"))  # Add header
        if items is not None:
            for item in items:
                qitem = QStandardItem(item)
                qitem.setFlags(~Qt.ItemIsEditable)
                qitem.setData(item, Qt.ToolTipRole)
                qitem.setData(
                    self._toolbox.style().standardIcon(QStyle.SP_FileLinkIcon),
                    Qt.DecorationRole)
                self.reference_model.appendRow(qitem)
        if emit_item_changed:
            self.item_changed.emit()

    def populate_data_list(self, items):
        """List project internal data (files) in QTreeView.
        If items is None or empty list, model is cleared.
        """
        self.data_model.clear()
        self.data_model.setHorizontalHeaderItem(
            0, QStandardItem("Data"))  # Add header
        if items is not None:
            for item in items:
                qitem = QStandardItem(item)
                qitem.setFlags(~Qt.ItemIsEditable)
                if item == 'datapackage.json':
                    qitem.setData(self.datapackage_icon, Qt.DecorationRole)
                else:
                    qitem.setData(QFileIconProvider().icon(QFileInfo(item)),
                                  Qt.DecorationRole)
                full_path = os.path.join(self.data_dir,
                                         item)  # For drag and drop
                qitem.setData(full_path, Qt.UserRole)
                self.data_model.appendRow(qitem)
        self.item_changed.emit()

    def update_name_label(self):
        """Update Data Connection tab name label. Used only when renaming project items."""
        self._properties_ui.label_dc_name.setText(self.name)

    def resources_for_direct_successors(self):
        """see base class"""
        refs = self.file_references()
        f_list = [os.path.join(self.data_dir, f) for f in self.data_files()]
        resources = [
            ProjectItemResource(self, "file", url=pathlib.Path(ref).as_uri())
            for ref in refs + f_list
        ]
        return resources

    def _do_handle_dag_changed(self, resources):
        """See base class."""
        if not self.file_references() and not self.data_files():
            self.add_notification(
                "This Data Connection does not have any references or data. "
                "Add some in the Data Connection Properties panel.")

    def item_dict(self):
        """Returns a dictionary corresponding to this item."""
        d = super().item_dict()
        # Convert paths to relative before saving
        d["references"] = [
            serialize_path(f, self._project.project_dir)
            for f in self.file_references()
        ]
        return d

    def rename(self, new_name):
        """Rename this item.

        Args:
            new_name (str): New name
        Returns:
            bool: True if renaming succeeded, False otherwise
        """
        dirs = self.data_dir_watcher.directories()
        if dirs:
            self.data_dir_watcher.removePaths(dirs)
        if not super().rename(new_name):
            self.data_dir_watcher.addPaths(dirs)
            return False
        self.data_dir_watcher.addPath(self.data_dir)
        self.refresh()
        return True

    def tear_down(self):
        """Tears down this item. Called by toolbox just before closing.
        Closes the SpineDatapackageWidget instances opened."""
        if self.spine_datapackage_form:
            self.spine_datapackage_form.close()
        watched_paths = self.data_dir_watcher.directories()
        if watched_paths:
            self.data_dir_watcher.removePaths(watched_paths)
        self.data_dir_watcher.deleteLater()

    def notify_destination(self, source_item):
        """See base class."""
        if source_item.item_type() == "Tool":
            self._logger.msg.emit(
                f"Link established. Tool <b>{source_item.name}</b> output files will be "
                f"passed as references to item <b>{self.name}</b> after execution."
            )
        elif source_item.item_type() in ["Data Store", "Importer"]:
            # Does this type of link do anything?
            self._logger.msg.emit("Link established.")
        else:
            super().notify_destination(source_item)

    @staticmethod
    def default_name_prefix():
        """See base class."""
        return "Data Connection"
예제 #3
0
class WebView(QWebEngineView):
	# Tell PageWidget that a file is dropped onto view.
	dropped_relay = Signal(QDropEvent)

	def __init__(self, image_path):
		'''
		Execution order: parent's __init__ -> self.event() -> rest code in self.__init__
		So, we must initialize at here, otherwise it will override value which is
		assigned in self.event().
		'''
		self.child_obj = None
		super(WebView, self).__init__(None)
		self.load(QUrl(image_path))
		self.watcher = QFileSystemWatcher()
		self.watcher.fileChanged.connect(self.refresh_image)
		# Register file watcher
		self.watcher.addPath(image_path)
		self.setContextMenuPolicy(Qt.NoContextMenu) # Disable right click context menu

	def dragEnterEvent(self, drag_enter_event): # QDragEnterEvent
		if drag_enter_event.mimeData().hasUrls():
			drag_enter_event.acceptProposedAction()

	# https://stackoverflow.com/a/4421835/4112667
	def dragMoveEvent(self, event):
		pass

	def dropEvent(self, drop_event): # QDropEvent
		self.dropped_relay.emit(drop_event)

	def refresh_image(self, image_path):
		if not os.path.isfile(image_path):
			return
		self.reload()

	'''
	https://forum.qt.io/post/549615
	This bug causes the mouse event is captured by child widget of QWebEngineView
	We register a filter to capture what we need.
	https://stackoverflow.com/a/33576854/4112667
	'''
	def event(self, event):
		if event.type() == QEvent.ChildAdded:
			obj = event.child()
			if obj is not None and type(obj) == QWidget:
				self.child_obj = obj
				self.child_obj.installEventFilter(self)
		return QWebEngineView.event(self, event)

	def eventFilter(self, obj, event):
		if obj == self.child_obj and event.type() == QEvent.Wheel:
			self.do_wheel(event)
			return True
		elif obj == self.child_obj and event.type() == QEvent.MouseButtonPress:
			self.start_mouse_pos = event.localPos()
			self.start_scroll_pos = self.page().scrollPosition()
			return QWebEngineView.eventFilter(self, obj, event) # Make scrollbar work normally
		elif obj == self.child_obj and event.type() == QEvent.MouseMove:
			# Only process mouse move with left button pressed
			if event.buttons() != Qt.LeftButton:
				return QWebEngineView.eventFilter(self, obj, event)
			# If Ctrl is pressed, use the default event handler. This enable select text.
			if QGuiApplication.queryKeyboardModifiers() == Qt.ControlModifier:
				return QWebEngineView.eventFilter(self, obj, event)
			'''
			OK. This is a mouse drag without Ctrl pressed. We will change it's behavior
			to something like ImageView.
			'''
			current_mouse_pos = event.localPos()
			delta = current_mouse_pos - self.start_mouse_pos
			'''
			Note: if mouse moves down 5 pixels, it's y-pos increase 5 pixels.
			The page should also move down 5 pixels which means the scrollbar should
			move up 5 pixels and the scroll position should decrease.
			'''
			target_scroll_pos = self.start_scroll_pos - delta

			'''
			Verify value range. I don't know if this is a must or whether this has any
			effects or not.
			'''
			target_scroll_x = target_scroll_pos.x()
			target_scroll_y = target_scroll_pos.y()
			if target_scroll_x < 0:
				target_scroll_x = 0
			if target_scroll_y < 0:
				target_scroll_y = 0
			if target_scroll_x > self.page().contentsSize().width():
				target_scroll_x = self.page().contentsSize().width()
			if target_scroll_y > self.page().contentsSize().height():
				target_scroll_y = self.page().contentsSize().height()

			'''
			We notice that if the page is zoomed in, the scrollbar moves faster than
			the mouse cursor. Besides, the scrollbar may jump a large distance towards
			the end in a weird way if you place the mouse cursor at the right bottom
			corner and try to drag the page towards the left upper corner several times.
			I don't know why but I print the target_scroll_pos which seems indeed correct.
			So I guess this may be caused by the scrollTo function in JavaScript. I think
			this function doesn't do things as I thought it would. Now that the page scrolls
			faster than mouse cursor dragging only after the page is zoomed in and it seems
			they have multiples relation, I guess if I divide the target_scroll_pos by
			zoomFactor, the page scrolling speed may be slow down and maybe scrollTo can
			scroll the page to correct position. Finally, this way is indeed the solution.
			However, I don't know why.
			If the page is zoomed out, it would be the opposite. The page scrolling speed
			should be sped up to match the mouse cursor.
			'''
			target_scroll_x /= self.zoomFactor()
			target_scroll_y /= self.zoomFactor()
			# https://forum.qt.io/topic/60091/scroll-a-qwebengineview/3
			self.page().runJavaScript(f"window.scrollTo({target_scroll_x}, {target_scroll_y})")
			return QWebEngineView.eventFilter(self, obj, event) # Make scrollbar work normally
		elif obj == self.child_obj and event.type() == QEvent.MouseButtonDblClick and event.buttons() == Qt.RightButton:
			self.setZoomFactor(1)
			return True

		return QWebEngineView.eventFilter(self, obj, event)

	def do_wheel(self, wheel_event): # QWheelEvent
		num_degrees = wheel_event.angleDelta().y() / 8
		num_steps = num_degrees / 15
		coefficient = 1 + (num_steps * 0.25)
		self.setZoomFactor(self.zoomFactor() * coefficient)
예제 #4
0
class ImageView(QGraphicsView):
	# Tell PageWidget that a file is dropped onto view.
	dropped_relay = Signal(QDropEvent)

	def __init__(self, image_path):
		super(ImageView, self).__init__(None)
		self.scene = QGraphicsScene()
		self.setScene(self.scene)
		self.pixmapitem = self.scene.addPixmap(QPixmap.fromImage(QImage(image_path)))
		self.last_release_time = 0
		self.watcher = QFileSystemWatcher()
		self.watcher.fileChanged.connect(self.refresh_image)
		# Register file watcher
		self.watcher.addPath(image_path)

	def dragEnterEvent(self, drag_enter_event): # QDragEnterEvent
		if drag_enter_event.mimeData().hasUrls():
			drag_enter_event.acceptProposedAction()

	# https://stackoverflow.com/a/4421835/4112667
	def dragMoveEvent(self, event):
		pass

	def dropEvent(self, drop_event): # QDropEvent
		self.dropped_relay.emit(drop_event)

	'''
	When overwriting an image file, I guess Windows will delete it and then create
	a new file with the same name. So this function will be called twice. The first
	round is triggered by deleting. In this case, the image file doesn't exist, so
	QImage and QPixmap are all invalid and as a result, the view will become white
	background. Only after the image being created and the function is called for
	the second time, will the view show the image normally. The User will notice a
	white flicker because of two rounds of callings. To resolve this problem, we
	need to detect the invalid QImage or QPixmap and skip the unintended round.
	'''
	def refresh_image(self, image_path):
		qimage = QImage(image_path)
		if qimage.isNull():
			return
		pixmap = QPixmap.fromImage(qimage)
		self.scene.removeItem(self.pixmapitem)
		self.pixmapitem = self.scene.addPixmap(pixmap)
		# This will make scrollbar fit the image
		self.setSceneRect(QRectF(pixmap.rect()))

	def mousePressEvent(self, mouse_event): # QMouseEvent
		if mouse_event.button() == Qt.LeftButton:
			self.setDragMode(QGraphicsView.ScrollHandDrag)
		elif mouse_event.button() == Qt.RightButton:
			self.setDragMode(QGraphicsView.RubberBandDrag)
		QGraphicsView.mousePressEvent(self, mouse_event)

	def mouseReleaseEvent(self, mouse_event): # QMouseEvent
		QGraphicsView.mouseReleaseEvent(self, mouse_event)
		if mouse_event.button() == Qt.LeftButton:
			self.setDragMode(QGraphicsView.NoDrag)
		elif mouse_event.button() == Qt.RightButton:
			self.setDragMode(QGraphicsView.NoDrag)

			now = time.time()
			delta = now - self.last_release_time
			self.last_release_time = now
			if delta < 0.3: # fast double click
				self.resetTransform() # Reset to original size (reset scale matrix)
				return
			# Maybe a selection
			selection = self.scene.selectionArea().boundingRect()
			self.scene.setSelectionArea(QPainterPath())
			if selection.isValid():
				self.fitInView(selection, Qt.KeepAspectRatio)

	def wheelEvent(self, wheel_event): # QWheelEvent
		num_degrees = wheel_event.angleDelta().y() / 8
		num_steps = num_degrees / 15
		coefficient = 1 + (num_steps * 0.25)
		self.scale(coefficient, coefficient)
class ConfigurationFile(dict):
    file_watcher: QFileSystemWatcher

    def __init__(self, path, **kwargs):
        super().__init__(**kwargs)
        self.path = path
        parent = os.path.abspath(os.path.join(path, pardir))
        if not isdir(parent):
            log.info('Creating directory', path=parent)
            os.mkdir(parent)
        if not isfile(self.path):
            log.info('Creating file', path=self.path)
            with open(self.path, 'w') as f:
                f.write('# Auto-Generated Configuration File' + os.linesep +
                        os.linesep)
                f.write(f'# Node Launcher version {NODE_LAUNCHER_RELEASE}' +
                        os.linesep + os.linesep)
                f.flush()

        self.cache = {}
        self.aliases = {
            'rpcport': 'main.rpcport',
            'main.rpcport': 'rpcport',
            'port': 'main.port',
            'main.port': 'port',
            'walletdir': 'main.walletdir',
            'main.walletdir': 'walletdir'
        }
        self.populate_cache()
        self.file_watcher = QFileSystemWatcher()
        self.file_watcher.addPath(self.path)

    def populate_cache(self):
        with open(self.path, 'r') as f:
            property_lines = f.readlines()
        self.cache = {}
        for property_line in property_lines:
            key_value = property_line.split('=')
            key = key_value[0]
            if not key.strip():
                continue
            value = key_value[1:]
            value = '='.join(value).strip()
            value = value.replace('"', '')
            if len(value) == 1 and value.isdigit():
                value = bool(int(value))
            elif value.isdigit():
                value = int(value)

            existing_value = self.cache.get(key, 'no_key')
            if existing_value == 'no_key':
                self.cache[key] = value
                if key in self.aliases.keys():
                    self.cache[self.aliases[key]] = value
            elif isinstance(existing_value, list):
                self.cache[key].append(value)
                if key in self.aliases.keys():
                    self.cache[self.aliases[key]].append(value)
            else:
                self.cache[key] = [existing_value, value]
                if key in self.aliases.keys():
                    self.cache[self.aliases[key]] = [existing_value, value]

    def __repr__(self):
        return f'ConfigurationFile: {self.path}'

    def __delitem__(self, v) -> None:
        raise NotImplementedError()

    def __len__(self) -> int:
        raise NotImplementedError()

    def __iter__(self):
        raise NotImplementedError()

    def __getitem__(self, name):
        if name in self.aliases.keys():
            if self.cache.get(self.aliases[name], None) is not None:
                return self.cache[self.aliases[name]]
            else:
                return self.cache.get(name, None)
        return self.cache.get(name, None)

    def __setitem__(self, name: str, value: Any) -> None:
        self.cache[name] = value
        if name in self.aliases.keys():
            self.cache[self.aliases[name]] = value
        if isinstance(value, str):
            value = [value]
        elif isinstance(value, bool):
            value = [str(int(value))]
        elif isinstance(value, int):
            value = [str(value)]
        elif isinstance(value, List):
            for item in value:
                assert isinstance(item, str)
            pass
        elif value is None:
            pass
        else:
            raise NotImplementedError(f'setattr for {type(value)}')

        self.write_property(name, value)

    def write_property(self, name: str, value_list: List[str]):
        with open(self.path, 'r') as f:
            lines = f.readlines()
            lines = [l.strip() for l in lines if l.strip()]
        property_lines = [
            line_number for line_number, l in enumerate(lines)
            if l.startswith(name)
        ]
        for property_line_index in property_lines:
            lines.pop(property_line_index)
        if value_list is not None:
            for value in value_list:
                property_string = f'{name.strip()}={value.strip()}'
                lines.append(property_string)
        with open(self.path, 'w') as f:
            lines = [l + os.linesep for l in lines]
            f.writelines(lines)

    @property
    def directory(self):
        directory_path = os.path.abspath(os.path.join(self.path, os.pardir))
        return directory_path

    @property
    def snapshot(self):
        return self.cache.copy()
예제 #6
0
class QFileSystemLibraryModel(QFileSystemModel):
    """
    File System Model for displaying QLibrary in MetalGUI
    Has additional FileWatcher added to keep track of edited
    QComponent files and, in developer mode,
    to alert the view/delegate to let the user know these files
    are dirty and refresh the design
    """
    FILENAME = 0  # Column index to display filenames
    REBUILD = 1  # Column index to display Rebuild button

    file_dirtied_signal = Signal()
    file_cleaned_signal = Signal()

    def __init__(self, parent: QWidget = None):
        """
        Initializes Model


        Args:
            parent(QWidget): Parent widget
        """
        super().__init__(parent)

        self.file_system_watcher = QFileSystemWatcher()
        self.dirtied_files = {}
        self.ignored_substrings = {'.cpython', '__pycache__'}
        self.is_dev_mode = False
        self.columns = ['QComponents', 'Rebuild Buttons']

    def is_valid_file(self, file: str) -> bool:
        """
        Whether it's a file the FileWatcher should track
        Args:
            file(str): Filename

        Returns:
            bool: Whether file is one the FileWatcher should track

        """
        for sub in self.ignored_substrings:
            if sub in file:
                return False
        return True

    def clean_file(self, filepath: str):
        """
        Remove file from the dirtied_files dictionary
        and remove any parent files who are only dirty due to
        this file. Emits file_cleaned_signal.
        Args:
            filepath(str):  File path of file to be cleaned

        """
        filename = self.filepath_to_filename(filepath)
        self.dirtied_files.pop(filename, f"failed to pop {filepath}")

        sep = os.sep if os.sep in filepath else '/'
        for file in filepath.split(sep):
            if file in self.dirtied_files:
                # if file was in dirtied files only because it is a parent dir
                # of filename, remove
                self.dirtied_files[file].discard(filename)

                if len(self.dirtied_files[file]) < 1:
                    self.dirtied_files.pop(file)
        self.file_cleaned_signal.emit()

    def dirty_file(self, filepath: str):
        """
        Adds file and parent directories to the dirtied_files dictionary.
        Emits file_dirtied_signal
        Args:
            filepath (str): Dirty file path

        """
        filename = self.filepath_to_filename(filepath)
        if not self.is_valid_file(filename):
            return

        sep = os.sep if os.sep in filepath else '/'
        for file in filepath.split(sep):

            if file in self.dirtied_files:
                self.dirtied_files[file].add(filename)
            else:
                self.dirtied_files[file] = {filename}

        # overwrite filename entry from above
        self.dirtied_files[filename] = {filepath}

        self.file_dirtied_signal.emit()

    def is_file_dirty(self, filepath: str) -> bool:
        """
        Checks whether file is dirty
        Args:
            filepath (str): File in question

        Returns:
            bool: Whether file is dirty

        """
        filename = self.filepath_to_filename(filepath)
        return filename in self.dirtied_files

    def filepath_to_filename(self, filepath: str) -> str:  # pylint: disable=R0201, no-self-use
        """
        Gets just the filename from the full filepath
        Args:
            filepath (str): Full file path

        Returns:
            str: Filename

        """

        # split on os.sep and / because PySide appears to sometimes use / on
        # certain Windows
        filename = filepath.split(os.sep)[-1].split('/')[-1]
        if '.py' in filename:
            return filename[:-len('.py')]
        return filename

    def setRootPath(self, path: str) -> QModelIndex:
        """
        Sets FileWatcher on root path and adds rootpath to model
        Args:
            path (str): Root path

        Returns:
            QModelIndex: Root index

        """

        for root, _, files in os.walk(path):
            # do NOT use directory changed -- fails for some reason
            for name in files:
                self.file_system_watcher.addPath(os.path.join(root, name))

        self.file_system_watcher.fileChanged.connect(self.alert_highlight_row)

        return super().setRootPath(path)

    def alert_highlight_row(self, filepath: str):
        """
        Dirties file and re-adds edited file to the FileWatcher
        Args:
            filepath (str): Dirty file


        """
        # ensure get only filename
        if filepath not in self.file_system_watcher.files():
            if os.path.exists(filepath):
                self.file_system_watcher.addPath(filepath)
        self.dirty_file(filepath)

    def headerData(self,
                   section: int,
                   orientation: Qt.Orientation,
                   role: int = ...) -> typing.Any:
        """ Set the headers to be displayed.

        Args:
            section (int): Section number
            orientation (Qt orientation): Section orientation
            role (Qt display role): Display role.  Defaults to DisplayRole.

        Returns:
            typing.Any: The header data, or None if not found
        """

        if role == Qt.DisplayRole:
            if not self.is_dev_mode and section == self.REBUILD:
                return ""

            if orientation == Qt.Horizontal:
                if section < len(self.columns):
                    return self.columns[section]

        elif role == Qt.FontRole:
            if section == 0:
                font = QFont()
                font.setBold(True)
                return font

        return super().headerData(section, orientation, role)

    def set_file_is_dev_mode(self, ison: bool):
        """
        Set dev_mode
        Args:
            ison(bool): Whether dev_mode is on

        """
        self.is_dev_mode = ison
예제 #7
0
파일: ui.py 프로젝트: ramesharun/caribou
class MainWindow(QMainWindow):
    def __init__(self, path):
        super().__init__()

        self.widget = None

        if path is None:
            path = load_setting('file_path')

        if path is None:
            path = self.query_new_path()

        if path is None:
            print('No file selected, exiting')
            sys.exit(1)

        self.open_file(path)

        self.statusBar()

        open_action = QAction('&Open', self)
        open_action.setShortcut('Ctrl+O')
        open_action.setStatusTip('Open config file')
        open_action.triggered.connect(self.query_open)

        reload_action = QAction('&Reload', self)
        reload_action.setShortcut('Ctrl+R')
        reload_action.setStatusTip('Reload config file')
        reload_action.triggered.connect(self.query_reload)

        menubar = self.menuBar()
        fileMenu = menubar.addMenu('&File')
        fileMenu.addAction(open_action)
        fileMenu.addAction(reload_action)

        # copy_curl_action = QAction('Copy curl command', self)
        # copy_curl_action.setStatusTip('Copy curl command')
        # copy_curl_action.triggered.connect(self.copy_curl_command)

        # routeMenu = menubar.addMenu('&Route')
        # routeMenu.addAction(copy_curl_action)

        self.setFont(FONT)
        self.setWindowTitle('Caribou')
        self.setWindowIcon(QIcon(os.path.join(CURRENT_DIR, 'icon.png')))

    def query_new_path(self):
        return QFileDialog.getOpenFileName(self, "Open File",
                                           os.path.expanduser("~"),
                                           "Python file (*.py)")[0]

    def query_open(self):
        path = self.query_new_path()

        if path is not None:
            self.open_file(path)

    def open_file(self, path):
        self.file_watcher = QFileSystemWatcher()
        self.file_watcher.addPath(path)
        self.file_watcher.fileChanged.connect(self.reload)

        save_setting('file_path', path)
        persist_storage()

        self.path = path
        self.reload(path)

    # def copy_curl_command(self):
    #     pass

    def query_reload(self):
        return self.reload(self.path)

    def reload(self, path):
        current_route = self.widget.selected_route if self.widget is not None else None
        current_search = self.widget.current_search(
        ) if self.widget is not None else None

        assert path == self.path
        try:
            routes = load_file(self.path)
        except Exception as e:
            msgBox = QMessageBox()
            if isinstance(e, CaribouException):
                msgBox.setText(str(e))
            else:
                msgBox.setText(traceback.format_exc())
            msgBox.exec_()

            routes = []

        if self.widget:
            self.widget.setParent(None)
        self.widget = MainWidget(routes)
        self.setCentralWidget(self.widget)

        if current_route is not None:
            self.widget.set_route_with_name(current_route.name)
        if current_search is not None:
            self.widget.set_search(current_search)
예제 #8
0
class Firefox(QObject):
    NAME: str = "Firefox"
    SESSION_LOCATION_COMMAND: list = [
        "find ~/.mozilla/firefox*/*.*/sessionstore-backups/recovery.jsonlz4"
    ]
    MOZILLA_MAGIC_NUMBER: int = 8  # NOTE: https://gist.github.com/mnordhoff/25e42a0d29e5c12785d0

    tabs_changed = Signal()

    def __init__(self):
        super(Firefox, self).__init__(None)

        self.logger = create_logger(__name__)
        self.tabs_model = WebTabsModel()
        self.file_expect = FileExpect()

        self.detect()

        self.file_expect.file_exists.connect(self.get_tabs)

    def detect(self) -> None:
        try:
            self.tabs_location = subprocess.check_output(
                Firefox.SESSION_LOCATION_COMMAND,
                shell=True).decode("utf-8").replace("\n", "")
            self.logger.info("Firefox detected={tabs_location}".format(
                tabs_location=(bool(self.tabs_location != ""))))

            self.tabs_file_watcher = QFileSystemWatcher()
            self.get_tabs(self.tabs_location)
            self.tabs_file_watcher.fileChanged.connect(self.get_tabs,
                                                       Qt.QueuedConnection)

            self.detected = True

        except subprocess.CalledProcessError as error:
            self.detected = False

    @Slot(str)
    def get_tabs(self, path: str) -> None:
        tabs = []

        if not os.path.isfile(path):
            self.file_expect.observe(path)
            return

        if path not in (self.tabs_file_watcher.files()):
            self.tabs_file_watcher.addPath(self.tabs_location)

        with open(self.tabs_location, "rb") as tabs_file:
            mozilla_magic = tabs_file.read(Firefox.MOZILLA_MAGIC_NUMBER)
            j_data = json.loads(
                lz4.block.decompress(tabs_file.read()).decode("utf-8"))

        for window in j_data.get("windows"):
            for tab in window.get("tabs"):
                index = int(tab.get("index")) - 1

                if (is_youtube(tab.get("entries")[index].get("url"))):
                    tabs.append(
                        BrowserTab(
                            tab.get("entries")[index].get("url"),
                            tab.get("entries")[index].get("title")))

        if (self.tabs_model.tabs != tabs):
            self.tabs_model.set_tabs(tabs)

    @Property(str, constant=True)
    def name(self) -> str:
        return Firefox.NAME

    @Property(QObject, constant=True)
    def tabs(self) -> WebTabsModel:
        return self.tabs_model
예제 #9
0
파일: utils.py 프로젝트: cs210/Worldsight
class QmlInstantEngine(QQmlApplicationEngine):
    """
    QmlInstantEngine is an utility class helping developing QML applications.
    It reloads itself whenever one of the watched source files is modified.
    As it consumes resources, make sure to disable file watching in production mode.
    """
    def __init__(self,
                 sourceFile="",
                 watching=True,
                 verbose=False,
                 parent=None):
        """
        watching -- Defines whether the watcher is active (default: True)
        verbose -- if True, output log infos (default: False)
        """
        super(QmlInstantEngine, self).__init__(parent)

        self._fileWatcher = QFileSystemWatcher()  # Internal Qt File Watcher
        self._sourceFile = ""
        self._watchedFiles = []  # Internal watched files list
        self._verbose = verbose  # Verbose bool
        self._watching = False  #
        self._extensions = [
            "qml", "js"
        ]  # File extensions that defines files to watch when adding a folder

        self._rootItem = None

        def onObjectCreated(root, url):
            if not root:
                return
            # Restore root item geometry
            if self._rootItem:
                root.setGeometry(self._rootItem.geometry())
                self._rootItem.deleteLater()
            self._rootItem = root

        self.objectCreated.connect(onObjectCreated)

        # Update the watching status
        self.setWatching(watching)

        if sourceFile:
            self.load(sourceFile)

    def load(self, sourceFile):
        self._sourceFile = sourceFile
        super(QmlInstantEngine, self).load(sourceFile)

    def setWatching(self, watchValue):
        """
        Enable (True) or disable (False) the file watching.
        Tip: file watching should be enable only when developing.
        """
        if self._watching is watchValue:
            return

        self._watching = watchValue
        # Enable the watcher
        if self._watching:
            # 1. Add internal list of files to the internal Qt File Watcher
            self.addFiles(self._watchedFiles)
            # 2. Connect 'filechanged' signal
            self._fileWatcher.fileChanged.connect(self.onFileChanged)

        # Disabling the watcher
        else:
            # 1. Remove all files in the internal Qt File Watcher
            self._fileWatcher.removePaths(self._watchedFiles)
            # 2. Disconnect 'filechanged' signal
            self._fileWatcher.fileChanged.disconnect(self.onFileChanged)

    @property
    def watchedExtensions(self):
        """ Returns the list of extensions used when using addFilesFromDirectory. """
        return self._extensions

    @watchedExtensions.setter
    def watchedExtensions(self, extensions):
        """ Set the list of extensions to search for when using addFilesFromDirectory. """
        self._extensions = extensions

    def setVerbose(self, verboseValue):
        """ Activate (True) or desactivate (False) the verbose. """
        self._verbose = verboseValue

    def addFile(self, filename):
        """
        Add the given 'filename' to the watched files list.
        'filename' can be an absolute or relative path (str and QUrl accepted)
        """
        # Deal with QUrl type
        # NOTE: happens when using the source() method on a QQuickView
        if isinstance(filename, QUrl):
            filename = filename.path()

        # Make sure the file exists
        if not os.path.isfile(filename):
            raise ValueError("addFile: file %s doesn't exist." % filename)

        # Return if the file is already in our internal list
        if filename in self._watchedFiles:
            return

        # Add this file to the internal files list
        self._watchedFiles.append(filename)
        # And, if watching is active, add it to the internal watcher as well
        if self._watching:
            if self._verbose:
                print("instantcoding: addPath", filename)
            self._fileWatcher.addPath(filename)

    def addFiles(self, filenames):
        """
        Add the given 'filenames' to the watched files list.
        filenames -- a list of absolute or relative paths (str and QUrl accepted)
        """
        # Convert to list
        if not isinstance(filenames, list):
            filenames = [filenames]

        for filename in filenames:
            self.addFile(filename)

    def addFilesFromDirectory(self, dirname, recursive=False):
        """
        Add files from the given directory name 'dirname'.
        dirname -- an absolute or a relative path
        recursive -- if True, will search inside each subdirectories recursively.
        """
        if not os.path.isdir(dirname):
            raise RuntimeError(
                "addFilesFromDirectory : %s is not a valid directory." %
                dirname)

        if recursive:
            for dirpath, dirnames, filenames in os.walk(dirname):
                for filename in filenames:
                    # Removing the starting dot from extension
                    if os.path.splitext(filename)[1][1:] in self._extensions:
                        self.addFile(os.path.join(dirpath, filename))
        else:
            filenames = os.listdir(dirname)
            filenames = [
                os.path.join(dirname, filename) for filename in filenames
                if os.path.splitext(filename)[1][1:] in self._extensions
            ]
            self.addFiles(filenames)

    def removeFile(self, filename):
        """
        Remove the given 'filename' from the watched file list.
        Tip: make sure to use relative or absolute path according to how you add this file.
        """
        if filename in self._watchedFiles:
            self._watchedFiles.remove(filename)
        if self._watching:
            self._fileWatcher.removePath(filename)

    def getRegisteredFiles(self):
        """ Returns the list of watched files """
        return self._watchedFiles

    @Slot(str)
    def onFileChanged(self, filepath):
        """ Handle changes in a watched file. """
        if filepath not in self._watchedFiles:
            # could happen if a file has just been reloaded
            # and has not been re-added yet to the watched files
            return

        if self._verbose:
            print("Source file changed : ", filepath)
        # Clear the QQuickEngine cache
        self.clearComponentCache()
        # Remove the modified file from the watched list
        self.removeFile(filepath)
        cptTry = 0

        # Make sure file is available before doing anything
        # NOTE: useful to handle editors (Qt Creator) that deletes the source file and
        #       creates a new one when saving
        while not os.path.exists(filepath) and cptTry < 10:
            time.sleep(0.1)
            cptTry += 1

        self.reload()

        # Finally, re-add the modified file to the watch system
        # after a short cooldown to avoid multiple consecutive reloads
        QTimer.singleShot(200, lambda: self.addFile(filepath))

    def reload(self):
        print("Reloading {}".format(self._sourceFile))
        self.load(self._sourceFile)
예제 #10
0
class SpineDatapackageWidget(QMainWindow):
    """A widget to edit CSV files in a Data Connection and create a tabular datapackage.
    """

    msg = Signal(str)
    msg_error = Signal(str)

    def __init__(self, datapackage):
        """Initialize class.

        Args:
            datapackage (CustomPackage): Data package associated to this widget
        """
        from ..ui.spine_datapackage_form import Ui_MainWindow  # pylint: disable=import-outside-toplevel

        super().__init__(flags=Qt.Window)
        self.datapackage = datapackage
        self.selected_resource_index = None
        self.resources_model = DatapackageResourcesModel(self, self.datapackage)
        self.fields_model = DatapackageFieldsModel(self, self.datapackage)
        self.foreign_keys_model = DatapackageForeignKeysModel(self, self.datapackage)
        self.resource_data_model = DatapackageResourceDataModel(self, self.datapackage)
        self.default_row_height = QFontMetrics(QFont("", 0)).lineSpacing()
        max_screen_height = max([s.availableSize().height() for s in QGuiApplication.screens()])
        self.visible_rows = int(max_screen_height / self.default_row_height)
        self.err_msg = QErrorMessage(self)
        self.notification_stack = NotificationStack(self)
        self._foreign_keys_context_menu = QMenu(self)
        self._file_watcher = QFileSystemWatcher(self)
        self._file_watcher.addPath(self.datapackage.base_path)
        self._changed_source_indexes = set()
        self.undo_group = QUndoGroup(self)
        self.undo_stacks = {}
        self._save_resource_actions = []
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        self.takeCentralWidget()
        self._before_save_all = self.ui.menuFile.insertSeparator(self.ui.actionSave_All)
        self.setWindowIcon(QIcon(":/symbols/app.ico"))
        self.qsettings = QSettings("SpineProject", "Spine Toolbox")
        self.restore_ui()
        self.add_menu_actions()
        self.setStyleSheet(MAINWINDOW_SS)
        self.ui.tableView_resources.setModel(self.resources_model)
        self.ui.tableView_resources.verticalHeader().setDefaultSectionSize(self.default_row_height)
        self.ui.tableView_resource_data.setModel(self.resource_data_model)
        self.ui.tableView_resource_data.verticalHeader().setDefaultSectionSize(self.default_row_height)
        self.ui.tableView_resource_data.horizontalHeader().setResizeContentsPrecision(self.visible_rows)
        self.ui.tableView_fields.setModel(self.fields_model)
        self.ui.tableView_fields.verticalHeader().setDefaultSectionSize(self.default_row_height)
        self.ui.tableView_fields.horizontalHeader().setResizeContentsPrecision(self.visible_rows)
        self.ui.tableView_foreign_keys.setModel(self.foreign_keys_model)
        self.ui.tableView_foreign_keys.verticalHeader().setDefaultSectionSize(self.default_row_height)
        self.ui.tableView_foreign_keys.horizontalHeader().setResizeContentsPrecision(self.visible_rows)
        self.connect_signals()
        self.setAttribute(Qt.WA_DeleteOnClose)
        self.setWindowTitle("{0}[*] - Spine datapackage manager".format(self.datapackage.base_path))
        self.load_datapackage()

    @property
    def undo_stack(self):
        return self.undo_group.activeStack()

    @property
    def datapackage_path(self):
        return os.path.join(self.datapackage.base_path, "datapackage.json")

    def load_datapackage(self):
        self._file_watcher.addPaths(self.datapackage.sources)
        self.append_save_resource_actions()
        self.resources_model.refresh_model()
        first_index = self.resources_model.index(0, 0)
        if not first_index.isValid():
            return
        self.ui.tableView_resources.selectionModel().setCurrentIndex(first_index, QItemSelectionModel.Select)

    def add_menu_actions(self):
        """Add extra menu actions."""
        self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_resources.toggleViewAction())
        self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_data.toggleViewAction())
        self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_fields.toggleViewAction())
        self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_foreign_keys.toggleViewAction())
        undo_action = self.undo_group.createUndoAction(self)
        redo_action = self.undo_group.createRedoAction(self)
        undo_action.setShortcuts(QKeySequence.Undo)
        redo_action.setShortcuts(QKeySequence.Redo)
        undo_action.setIcon(QIcon(":/icons/menu_icons/undo.svg"))
        redo_action.setIcon(QIcon(":/icons/menu_icons/redo.svg"))
        before = self.ui.menuEdit.actions()[0]
        self.ui.menuEdit.insertAction(before, undo_action)
        self.ui.menuEdit.insertAction(before, redo_action)
        self.ui.menuEdit.insertSeparator(before)

    def connect_signals(self):
        """Connect signals to slots."""
        self.msg.connect(self.add_message)
        self.msg_error.connect(self.add_error_message)
        self._file_watcher.directoryChanged.connect(self._handle_source_dir_changed)
        self._file_watcher.fileChanged.connect(self._handle_source_file_changed)
        self.ui.actionCopy.triggered.connect(self.copy)
        self.ui.actionPaste.triggered.connect(self.paste)
        self.ui.actionClose.triggered.connect(self.close)
        self.ui.actionSave_All.triggered.connect(self.save_all)
        self.ui.actionSave_datapackage.triggered.connect(self.save_datapackage)
        self.ui.menuEdit.aboutToShow.connect(self.refresh_copy_paste_actions)
        self.fields_model.dataChanged.connect(self._handle_fields_data_changed)
        self.undo_group.cleanChanged.connect(self.update_window_modified)
        checkbox_delegate = CheckBoxDelegate(self)
        checkbox_delegate.data_committed.connect(self.fields_model.setData)
        self.ui.tableView_fields.setItemDelegateForColumn(2, checkbox_delegate)
        foreign_keys_delegate = ForeignKeysDelegate(self)
        foreign_keys_delegate.data_committed.connect(self.foreign_keys_model.setData)
        self.ui.tableView_foreign_keys.setItemDelegate(foreign_keys_delegate)
        self.ui.tableView_resources.selectionModel().currentChanged.connect(self._handle_current_resource_changed)
        self.ui.tableView_foreign_keys.customContextMenuRequested.connect(self.show_foreign_keys_context_menu)
        self._foreign_keys_context_menu.addAction("Remove foreign key", self._remove_foreign_key)

    @Slot(bool)
    def update_window_modified(self, _clean=None):
        """Updates window modified status and save actions depending on the state of the undo stack."""
        try:
            dirty_resource_indexes = {
                idx for idx in range(len(self.datapackage.resources)) if self.is_resource_dirty(idx)
            }
            dirty = bool(dirty_resource_indexes)
            self.setWindowModified(dirty)
        except RuntimeError:
            return
        self.ui.actionSave_datapackage.setEnabled(dirty)
        self.ui.actionSave_All.setEnabled(dirty)
        for idx, action in enumerate(self._save_resource_actions):
            dirty = idx in dirty_resource_indexes
            action.setEnabled(dirty)
            self.resources_model.update_resource_dirty(idx, dirty)

    def is_resource_dirty(self, resource_index):
        if resource_index in self._changed_source_indexes:
            return True
        try:
            return not self.undo_stacks[resource_index].isClean()
        except KeyError:
            return False

    def get_undo_stack(self, resource_index):
        if resource_index not in self.undo_stacks:
            self.undo_stacks[resource_index] = stack = QUndoStack(self.undo_group)
            stack.cleanChanged.connect(self.update_window_modified)
        return self.undo_stacks[resource_index]

    @Slot(str)
    def _handle_source_dir_changed(self, _path):
        if not self.datapackage.resources:
            self.load_datapackage()
            return
        self.datapackage.difference_infer(os.path.join(self.datapackage.base_path, '*.csv'))
        self._file_watcher.addPaths(self.datapackage.sources)
        self.append_save_resource_actions()
        self.resources_model.refresh_model()
        self.refresh_models()

    @Slot(str)
    def _handle_source_file_changed(self, path):
        for idx, source in enumerate(self.datapackage.sources):
            if os.path.normpath(source) == os.path.normpath(path):
                self._changed_source_indexes.add(idx)
                self.update_window_modified()
                break

    def append_save_resource_actions(self):
        new_actions = []
        for resource_index in range(len(self._save_resource_actions), len(self.datapackage.resources)):
            resource = self.datapackage.resources[resource_index]
            action = QAction(f"Save '{os.path.basename(resource.source)}'")
            action.setEnabled(False)
            action.triggered.connect(
                lambda checked=False, resource_index=resource_index: self.save_resource(resource_index)
            )
            new_actions.append(action)
        self.ui.menuFile.insertActions(self._before_save_all, new_actions)
        self._save_resource_actions += new_actions

    @Slot()
    def refresh_copy_paste_actions(self):
        """Adjusts copy and paste actions depending on which widget has the focus.
        """
        self.ui.actionCopy.setEnabled(focused_widget_has_callable(self, "copy"))
        self.ui.actionPaste.setEnabled(focused_widget_has_callable(self, "paste"))

    @Slot(str)
    def add_message(self, msg):
        """Prepend regular message to status bar.

        Args:
            msg (str): String to show in QStatusBar
        """
        self.notification_stack.push(msg)

    @Slot(str)
    def add_error_message(self, msg):
        """Show error message.

        Args:
            msg (str): String to show
        """
        self.err_msg.showMessage(msg)

    @Slot(bool)
    def save_all(self, _=False):
        resource_paths = {k: r.source for k, r in enumerate(self.datapackage.resources) if self.is_resource_dirty(k)}
        all_paths = list(resource_paths.values()) + [self.datapackage_path]
        if not self.get_permission(*all_paths):
            return
        for k, path in resource_paths.items():
            self._save_resource(k, path)
        self.save_datapackage()

    @Slot(bool)
    def save_datapackage(self, _=False):
        if self.datapackage.save(self.datapackage_path):
            self.msg.emit("'datapackage.json' succesfully saved")
            return
        self.msg_error.emit("Failed to save 'datapackage.json'")

    def save_resource(self, resource_index):
        resource = self.datapackage.resources[resource_index]
        filepath = resource.source
        if not self.get_permission(filepath, self.datapackage_path):
            return
        self._save_resource(resource_index, filepath)
        self.save_datapackage()

    def _save_resource(self, resource_index, filepath):
        headers = self.datapackage.resources[resource_index].schema.field_names
        self._file_watcher.removePath(filepath)
        with open(filepath, 'w', newline='') as csvfile:
            writer = csv.writer(csvfile)
            writer.writerow(headers)
            for row in self.datapackage.resource_data(resource_index):
                writer.writerow(row)
        self.msg.emit(f"'{os.path.basename(filepath)}' succesfully saved")
        self._file_watcher.addPath(filepath)
        self._changed_source_indexes.discard(resource_index)
        stack = self.undo_stacks.get(resource_index)
        if not stack or stack.isClean():
            self.update_window_modified()
        elif stack:
            stack.setClean()

    def get_permission(self, *filepaths):
        start_dir = self.datapackage.base_path
        filepaths = [os.path.relpath(path, start_dir) for path in filepaths if os.path.isfile(path)]
        if not filepaths:
            return True
        pathlist = "".join([f"<li>{path}</li>" for path in filepaths])
        msg = f"The following file(s) in <b>{os.path.basename(start_dir)}</b> will be replaced: <ul>{pathlist}</ul>. Are you sure?"
        message_box = QMessageBox(
            QMessageBox.Question, "Replacing file(s)", msg, QMessageBox.Ok | QMessageBox.Cancel, parent=self
        )
        message_box.button(QMessageBox.Ok).setText("Replace")
        return message_box.exec_() != QMessageBox.Cancel

    @Slot(bool)
    def copy(self, checked=False):
        """Copies data to clipboard."""
        call_on_focused_widget(self, "copy")

    @Slot(bool)
    def paste(self, checked=False):
        """Pastes data from clipboard."""
        call_on_focused_widget(self, "paste")

    @Slot("QModelIndex", "QModelIndex")
    def _handle_current_resource_changed(self, current, _previous):
        """Resets resource data and schema models whenever a new resource is selected."""
        self.refresh_models(current)

    def refresh_models(self, current=None):
        if current is None:
            current = self.ui.tableView_resources.selectionModel().currentIndex()
        if current.column() != 0 or current.row() == self.selected_resource_index:
            return
        self.selected_resource_index = current.row()
        self.get_undo_stack(self.selected_resource_index).setActive()
        self.resource_data_model.refresh_model(self.selected_resource_index)
        self.fields_model.refresh_model(self.selected_resource_index)
        self.foreign_keys_model.refresh_model(self.selected_resource_index)
        self.ui.tableView_resource_data.resizeColumnsToContents()
        self.ui.tableView_fields.resizeColumnsToContents()
        self.ui.tableView_foreign_keys.resizeColumnsToContents()

    @Slot("QModelIndex", "QModelIndex", "QVector<int>")
    def _handle_fields_data_changed(self, top_left, bottom_right, roles):
        top, left = top_left.row(), top_left.column()
        bottom, right = bottom_right.row(), bottom_right.column()
        if left <= 0 <= right and Qt.DisplayRole in roles:
            # Fields name changed
            self.resource_data_model.headerDataChanged.emit(Qt.Horizontal, top, bottom)
            self.ui.tableView_resource_data.resizeColumnsToContents()
            self.foreign_keys_model.emit_data_changed()

    @Slot("QPoint")
    def show_foreign_keys_context_menu(self, pos):
        index = self.ui.tableView_foreign_keys.indexAt(pos)
        if not index.isValid() or index.row() == index.model().rowCount() - 1:
            return
        global_pos = self.ui.tableView_foreign_keys.viewport().mapToGlobal(pos)
        self._foreign_keys_context_menu.popup(global_pos)

    @Slot(bool)
    def _remove_foreign_key(self, checked=False):
        index = self.ui.tableView_foreign_keys.currentIndex()
        if not index.isValid():
            return
        index.model().call_remove_foreign_key(index.row())

    def restore_ui(self):
        """Restore UI state from previous session."""
        window_size = self.qsettings.value("dataPackageWidget/windowSize")
        window_pos = self.qsettings.value("dataPackageWidget/windowPosition")
        window_maximized = self.qsettings.value("dataPackageWidget/windowMaximized", defaultValue='false')
        window_state = self.qsettings.value("dataPackageWidget/windowState")
        n_screens = self.qsettings.value("mainWindow/n_screens", defaultValue=1)
        original_size = self.size()
        if window_size:
            self.resize(window_size)
        if window_pos:
            self.move(window_pos)
        # noinspection PyArgumentList
        if len(QGuiApplication.screens()) < int(n_screens):
            # There are less screens available now than on previous application startup
            self.move(0, 0)  # Move this widget to primary screen position (0,0)
        ensure_window_is_on_screen(self, original_size)
        if window_maximized == 'true':
            self.setWindowState(Qt.WindowMaximized)
        if window_state:
            self.restoreState(window_state, version=1)  # Toolbar and dockWidget positions

    def closeEvent(self, event=None):
        """Handle close event.

        Args:
            event (QEvent): Closing event if 'X' is clicked.
        """
        # save qsettings
        self.qsettings.setValue("dataPackageWidget/windowSize", self.size())
        self.qsettings.setValue("dataPackageWidget/windowPosition", self.pos())
        self.qsettings.setValue("dataPackageWidget/windowState", self.saveState(version=1))
        self.qsettings.setValue("dataPackageWidget/windowMaximized", self.windowState() == Qt.WindowMaximized)
        self.qsettings.setValue("dataPackageWidget/n_screens", len(QGuiApplication.screens()))
        if event:
            event.accept()
예제 #11
0
class DataConnection(ProjectItem):
    """Data Connection class.

    Attributes:
        toolbox (ToolboxUI): QMainWindow instance
        name (str): Object name
        description (str): Object description
        references (list): List of file references
        x (int): Initial X coordinate of item icon
        y (int): Initial Y coordinate of item icon
    """
    def __init__(self, toolbox, name, description, references, x, y):
        """Class constructor."""
        super().__init__(name, description)
        self._toolbox = toolbox
        self._project = self._toolbox.project()
        self.item_type = "Data Connection"
        # self._widget = DataConnectionWidget(self, self.item_type)
        self.reference_model = QStandardItemModel()  # References to files
        self.data_model = QStandardItemModel(
        )  # Paths of project internal files. These are found in DC data directory
        self.datapackage_icon = QIcon(QPixmap(":/icons/datapkg.png"))
        self.data_dir_watcher = QFileSystemWatcher(self)
        # Make project directory for this Data Connection
        self.data_dir = os.path.join(self._project.project_dir,
                                     self.short_name)
        try:
            create_dir(self.data_dir)
            self.data_dir_watcher.addPath(self.data_dir)
        except OSError:
            self._toolbox.msg_error.emit(
                "[OSError] Creating directory {0} failed."
                " Check permissions.".format(self.data_dir))
        # Populate references model
        self.references = references
        self.populate_reference_list(self.references)
        # Populate data (files) model
        data_files = self.data_files()
        self.populate_data_list(data_files)
        self._graphics_item = DataConnectionImage(self._toolbox, x - 35,
                                                  y - 35, 70, 70, self.name)
        self.spine_datapackage_form = None
        # self.ui.toolButton_datapackage.setMenu(self.datapackage_popup_menu)  # TODO: OBSOLETE?
        self._sigs = self.make_signal_handler_dict()

    def make_signal_handler_dict(self):
        """Returns a dictionary of all shared signals and their handlers.
        This is to enable simpler connecting and disconnecting."""
        s = dict()
        s[self._toolbox.ui.toolButton_dc_open_dir.
          clicked] = self.open_directory
        s[self._toolbox.ui.toolButton_plus.clicked] = self.add_references
        s[self._toolbox.ui.toolButton_minus.clicked] = self.remove_references
        s[self._toolbox.ui.toolButton_add.clicked] = self.copy_to_project
        s[self._toolbox.ui.pushButton_datapackage.
          clicked] = self.show_spine_datapackage_form
        s[self._toolbox.ui.treeView_dc_references.
          doubleClicked] = self.open_reference
        s[self._toolbox.ui.treeView_dc_data.
          doubleClicked] = self.open_data_file
        s[self.data_dir_watcher.directoryChanged] = self.refresh
        s[self._toolbox.ui.treeView_dc_references.
          files_dropped] = self.add_files_to_references
        s[self._toolbox.ui.treeView_dc_data.
          files_dropped] = self.add_files_to_data_dir
        s[self._graphics_item.master().scene().
          files_dropped_on_dc] = self.receive_files_dropped_on_dc
        return s

    def activate(self):
        """Restore selections and connect signals."""
        self.restore_selections(
        )  # Do this before connecting signals or funny things happen
        super().connect_signals()

    def deactivate(self):
        """Save selections and disconnect signals."""
        self.save_selections()
        if not super().disconnect_signals():
            logging.error("Item {0} deactivation failed".format(self.name))
            return False
        return True

    def restore_selections(self):
        """Restore selections into shared widgets when this project item is selected."""
        self._toolbox.ui.label_dc_name.setText(self.name)
        self._toolbox.ui.treeView_dc_references.setModel(self.reference_model)
        self._toolbox.ui.treeView_dc_data.setModel(self.data_model)
        self.refresh()

    def save_selections(self):
        """Save selections in shared widgets for this project item into instance variables."""
        pass

    def set_icon(self, icon):
        self._graphics_item = icon

    def get_icon(self):
        """Returns the item representing this data connection in the scene."""
        return self._graphics_item

    @Slot("QVariant", name="add_files_to_references")
    def add_files_to_references(self, paths):
        """Add multiple file paths to reference list.

        Args:
            paths (list): A list of paths to files
        """
        for path in paths:
            if path in self.references:
                self._toolbox.msg_warning.emit(
                    "Reference to file <b>{0}</b> already available".format(
                        path))
                return
            self.references.append(os.path.abspath(path))
        self.populate_reference_list(self.references)

    @Slot("QGraphicsItem", "QVariant", name="receive_files_dropped_on_dc")
    def receive_files_dropped_on_dc(self, item, file_paths):
        """Called when files are dropped onto a data connection graphics item.
        If the item is this Data Connection's graphics item, add the files to data."""
        if item == self._graphics_item:
            self.add_files_to_data_dir(file_paths)

    @Slot("QVariant", name="add_files_to_data_dir")
    def add_files_to_data_dir(self, file_paths):
        """Add files to data directory"""
        for file_path in file_paths:
            src_dir, filename = os.path.split(file_path)
            self._toolbox.msg.emit(
                "Copying file <b>{0}</b> to <b>{1}</b>".format(
                    filename, self.name))
            try:
                shutil.copy(file_path, self.data_dir)
            except OSError:
                self._toolbox.msg_error.emit("[OSError] Copying failed")
                return
        data_files = self.data_files()
        self.populate_data_list(data_files)

    @Slot(bool, name="open_directory")
    def open_directory(self, checked=False):
        """Open file explorer in Data Connection data directory."""
        url = "file:///" + self.data_dir
        # noinspection PyTypeChecker, PyCallByClass, PyArgumentList
        res = QDesktopServices.openUrl(QUrl(url, QUrl.TolerantMode))
        if not res:
            self._toolbox.msg_error.emit(
                "Failed to open directory: {0}".format(self.data_dir))

    @Slot(bool, name="add_references")
    def add_references(self, checked=False):
        """Let user select references to files for this data connection."""
        # noinspection PyCallByClass, PyTypeChecker, PyArgumentList
        answer = QFileDialog.getOpenFileNames(self._toolbox,
                                              "Add file references",
                                              APPLICATION_PATH, "*.*")
        file_paths = answer[0]
        if not file_paths:  # Cancel button clicked
            return
        for path in file_paths:
            if path in self.references:
                self._toolbox.msg_warning.emit(
                    "Reference to file <b>{0}</b> already available".format(
                        path))
                continue
            self.references.append(os.path.abspath(path))
        self.populate_reference_list(self.references)

    @Slot(bool, name="remove_references")
    def remove_references(self, checked=False):
        """Remove selected references from reference list.
        Do not remove anything if there are no references selected.
        """
        indexes = self._toolbox.ui.treeView_dc_references.selectedIndexes()
        if not indexes:  # Nothing selected
            self._toolbox.msg.emit("Please select references to remove")
            return
        else:
            rows = [ind.row() for ind in indexes]
            rows.sort(reverse=True)
            for row in rows:
                self.references.pop(row)
            self._toolbox.msg.emit("Selected references removed")
        self.populate_reference_list(self.references)

    @Slot(bool, name="copy_to_project")
    def copy_to_project(self, checked=False):
        """Copy selected file references to this Data Connection's data directory."""
        selected_indexes = self._toolbox.ui.treeView_dc_references.selectedIndexes(
        )
        if len(selected_indexes) == 0:
            self._toolbox.msg_warning.emit("No files to copy")
            return
        for index in selected_indexes:
            file_path = self.reference_model.itemFromIndex(index).data(
                Qt.DisplayRole)
            if not os.path.exists(file_path):
                self._toolbox.msg_error.emit(
                    "File <b>{0}</b> does not exist".format(file_path))
                continue
            src_dir, filename = os.path.split(file_path)
            self._toolbox.msg.emit(
                "Copying file <b>{0}</b> to Data Connection <b>{1}</b>".format(
                    filename, self.name))
            try:
                shutil.copy(file_path, self.data_dir)
            except OSError:
                self._toolbox.msg_error.emit("[OSError] Copying failed")
                continue

    @Slot("QModelIndex", name="open_reference")
    def open_reference(self, index):
        """Open reference in default program."""
        if not index:
            return
        if not index.isValid():
            logging.error("Index not valid")
            return
        else:
            reference = self.file_references()[index.row()]
            url = "file:///" + reference
            # noinspection PyTypeChecker, PyCallByClass, PyArgumentList
            res = QDesktopServices.openUrl(QUrl(url, QUrl.TolerantMode))
            if not res:
                self._toolbox.msg_error.emit(
                    "Failed to open reference:<b>{0}</b>".format(reference))

    @Slot("QModelIndex", name="open_data_file")
    def open_data_file(self, index):
        """Open data file in default program."""
        if not index:
            return
        if not index.isValid():
            logging.error("Index not valid")
            return
        else:
            data_file = self.data_files()[index.row()]
            url = "file:///" + os.path.join(self.data_dir, data_file)
            # noinspection PyTypeChecker, PyCallByClass, PyArgumentList
            res = QDesktopServices.openUrl(QUrl(url, QUrl.TolerantMode))
            if not res:
                self._toolbox.msg_error.emit(
                    "Opening file <b>{0}</b> failed".format(data_file))

    @busy_effect
    def show_spine_datapackage_form(self):
        """Show spine_datapackage_form widget."""
        if self.spine_datapackage_form:
            if self.spine_datapackage_form.windowState() & Qt.WindowMinimized:
                # Remove minimized status and restore window with the previous state (maximized/normal state)
                self.spine_datapackage_form.setWindowState(
                    self.spine_datapackage_form.windowState()
                    & ~Qt.WindowMinimized | Qt.WindowActive)
                self.spine_datapackage_form.activateWindow()
            else:
                self.spine_datapackage_form.raise_()
            return
        self.spine_datapackage_form = SpineDatapackageWidget(self)
        self.spine_datapackage_form.destroyed.connect(
            self.datapackage_form_destroyed)
        self.spine_datapackage_form.show()

    @Slot(name="datapackage_form_destroyed")
    def datapackage_form_destroyed(self):
        self.spine_datapackage_form = None

    def make_new_file(self):
        """Create a new blank file to this Data Connections data directory."""
        msg = "File name"
        # noinspection PyCallByClass, PyTypeChecker, PyArgumentList
        answer = QInputDialog.getText(self._toolbox,
                                      "Create new file",
                                      msg,
                                      flags=Qt.WindowTitleHint
                                      | Qt.WindowCloseButtonHint)
        file_name = answer[0]
        if not file_name:  # Cancel button clicked
            return
        if file_name.strip() == "":
            return
        # Check that file name has no invalid chars
        if any(True for x in file_name if x in INVALID_FILENAME_CHARS):
            msg = "File name <b>{0}</b> contains invalid characters.".format(
                file_name)
            # noinspection PyTypeChecker, PyArgumentList, PyCallByClass
            QMessageBox.information(self._toolbox, "Creating file failed", msg)
            return
        file_path = os.path.join(self.data_dir, file_name)
        if os.path.exists(file_path):
            msg = "File <b>{0}</b> already exists.".format(file_name)
            # noinspection PyTypeChecker, PyArgumentList, PyCallByClass
            QMessageBox.information(self._toolbox, "Creating file failed", msg)
            return
        try:
            with open(file_path, "w") as fp:
                self._toolbox.msg.emit(
                    "File <b>{0}</b> created to Data Connection <b>{1}</b>".
                    format(file_name, self.name))
        except OSError:
            msg = "Please check directory permissions."
            # noinspection PyTypeChecker, PyArgumentList, PyCallByClass
            QMessageBox.information(self._toolbox, "Creating file failed", msg)
        return

    def remove_files(self):
        """Remove selected files from data directory."""
        indexes = self._toolbox.ui.treeView_dc_data.selectedIndexes()
        if not indexes:  # Nothing selected
            self._toolbox.msg.emit("Please select files to remove")
            return
        else:
            file_list = list()
            for index in indexes:
                file_at_index = self.data_model.itemFromIndex(index).data(
                    Qt.DisplayRole)
                file_list.append(file_at_index)
            files = "\n".join(file_list)
            msg = "The following files will be removed permanently from the project\n\n" \
                  "{0}\n\n" \
                  "Are you sure?".format(files)
            # noinspection PyCallByClass, PyTypeChecker
            answer = QMessageBox.question(
                self._toolbox, "Remove {0} file(s)?".format(len(file_list)),
                msg, QMessageBox.Yes, QMessageBox.No)
            if not answer == QMessageBox.Yes:
                return
            for filename in file_list:
                path_to_remove = os.path.join(self.data_dir, filename)
                try:
                    os.remove(path_to_remove)
                    self._toolbox.msg.emit(
                        "File <b>{0}</b> removed".format(path_to_remove))
                except OSError:
                    self._toolbox.msg_error.emit(
                        "Removing file {0} failed.\nCheck permissions.".format(
                            path_to_remove))
        return

    def file_references(self):
        """Return a list of paths to files that are in this item as references."""
        return self.references

    def data_files(self):
        """Return a list of files that are in the data directory."""
        if not os.path.isdir(self.data_dir):
            return None
        return os.listdir(self.data_dir)

    @Slot(name="refresh")
    def refresh(self):
        """Refresh data files QTreeView.
        NOTE: Might lead to performance issues."""
        d = self.data_files()
        self.populate_data_list(d)

    def find_file(self, fname, visited_items):
        """Search for filename in references and data and return the path if found.
        Args:
            fname (str): File name (no path)
            visited_items (list): List of project item names that have been visited

        Returns:
            Full path to file that matches the given file name or None if not found.
        """
        # logging.debug("Looking for file {0} in DC {1}.".format(fname, self.name))
        if self in visited_items:
            self._toolbox.msg_warning.emit(
                "There seems to be an infinite loop in your project. Please fix the "
                "connections and try again. Detected at {0}.".format(
                    self.name))
            return None
        if fname in self.data_files():
            # logging.debug("{0} found in DC {1}".format(fname, self.name))
            self._toolbox.msg.emit(
                "\t<b>{0}</b> found in Data Connection <b>{1}</b>".format(
                    fname, self.name))
            path = os.path.join(self.data_dir, fname)
            return path
        for path in self.file_references(
        ):  # List of paths including file name
            p, fn = os.path.split(path)
            if fn == fname:
                # logging.debug("{0} found in DC {1}".format(fname, self.name))
                self._toolbox.msg.emit(
                    "\tReference for <b>{0}</b> found in Data Connection <b>{1}</b>"
                    .format(fname, self.name))
                return path
        visited_items.append(self)
        for input_item in self._toolbox.connection_model.input_items(
                self.name):
            # Find item from project model
            found_index = self._toolbox.project_item_model.find_item(
                input_item)
            if not found_index:
                self._toolbox.msg_error.emit(
                    "Item {0} not found. Something is seriously wrong.".format(
                        input_item))
                continue
            item = self._toolbox.project_item_model.project_item(found_index)
            if item.item_type in ["Data Store", "Data Connection"]:
                path = item.find_file(fname, visited_items)
                if path is not None:
                    return path
        return None

    def find_files(self, pattern, visited_items):
        """Search for files matching the given pattern (with wildcards) in references
        and data and return a list of matching paths.

        Args:
            pattern (str): File name (no path). May contain wildcards.
            visited_items (list): List of project item names that have been visited

        Returns:
            List of matching paths. List is empty if no matches found.
        """
        paths = list()
        if self in visited_items:
            self._toolbox.msg_warning.emit(
                "There seems to be an infinite loop in your project. Please fix the "
                "connections and try again. Detected at {0}.".format(
                    self.name))
            return paths
        # Search files that match the pattern from this Data Connection's data directory
        for data_file in self.data_files(
        ):  # data_file is a filename (no path)
            if fnmatch.fnmatch(data_file, pattern):
                # self._toolbox.msg.emit("\t<b>{0}</b> matches pattern <b>{1}</b> in Data Connection <b>{2}</b>"
                #                        .format(data_file, pattern, self.name))
                path = os.path.join(self.data_dir, data_file)
                paths.append(path)
        # Search files that match the pattern from this Data Connection's references
        for ref_file in self.file_references(
        ):  # List of paths including file name
            p, fn = os.path.split(ref_file)
            if fnmatch.fnmatch(fn, pattern):
                # self._toolbox.msg.emit("\tReference <b>{0}</b> matches pattern <b>{1}</b> "
                #                        "in Data Connection <b>{2}</b>".format(fn, pattern, self.name))
                paths.append(ref_file)
        visited_items.append(self)
        # Find items that are connected to this Data Connection
        for input_item in self._toolbox.connection_model.input_items(
                self.name):
            found_index = self._toolbox.project_item_model.find_item(
                input_item)
            if not found_index:
                self._toolbox.msg_error.emit(
                    "Item {0} not found. Something is seriously wrong.".format(
                        input_item))
                continue
            item = self._toolbox.project_item_model.project_item(found_index)
            if item.item_type in ["Data Store", "Data Connection"]:
                matching_paths = item.find_files(pattern, visited_items)
                if matching_paths is not None:
                    paths = paths + matching_paths
                    return paths
        return paths

    def populate_reference_list(self, items):
        """List file references in QTreeView.
        If items is None or empty list, model is cleared.
        """
        self.reference_model.clear()
        self.reference_model.setHorizontalHeaderItem(
            0, QStandardItem("References"))  # Add header
        if items is not None:
            for item in items:
                qitem = QStandardItem(item)
                qitem.setFlags(~Qt.ItemIsEditable)
                qitem.setData(item, Qt.ToolTipRole)
                qitem.setData(
                    self._toolbox.style().standardIcon(QStyle.SP_FileLinkIcon),
                    Qt.DecorationRole)
                self.reference_model.appendRow(qitem)

    def populate_data_list(self, items):
        """List project internal data (files) in QTreeView.
        If items is None or empty list, model is cleared.
        """
        self.data_model.clear()
        self.data_model.setHorizontalHeaderItem(
            0, QStandardItem("Data"))  # Add header
        if items is not None:
            for item in items:
                qitem = QStandardItem(item)
                qitem.setFlags(~Qt.ItemIsEditable)
                if item == 'datapackage.json':
                    qitem.setData(self.datapackage_icon, Qt.DecorationRole)
                else:
                    qitem.setData(QFileIconProvider().icon(QFileInfo(item)),
                                  Qt.DecorationRole)
                full_path = os.path.join(self.data_dir,
                                         item)  # For drag and drop
                qitem.setData(full_path, Qt.UserRole)
                self.data_model.appendRow(qitem)

    def update_name_label(self):
        """Update Data Connection tab name label. Used only when renaming project items."""
        self._toolbox.ui.label_dc_name.setText(self.name)
예제 #12
0
class Pqgit(QMainWindow):
    """ main class / entry point """
    def __init__(self):
        super().__init__()
        self.setAttribute(
            Qt.WA_DeleteOnClose
        )  # let Qt delete stuff before the python garbage-collector gets to work
        self.repo = None
        self.branches_model = None

        # instantiate main window
        self.ui = ui.Ui_MainWindow()
        self.ui.setupUi(self)

        self.fs_watch = QFileSystemWatcher(self)
        self.fs_watch.fileChanged.connect(self.on_file_changed)
        self.fs_watch.directoryChanged.connect(self.on_dir_changed)

        self.settings = QSettings(QSettings.IniFormat, QSettings.UserScope,
                                  'pqgit', 'config')

        # for comparison
        self.new_c_id, self.old_c_id = None, None

        # window icon
        cwd = os.path.dirname(os.path.realpath(__file__))
        self.setWindowIcon(QIcon(os.path.join(cwd, 'Git-Icon-White.png')))
        self.setWindowTitle('pqgit')

        # size and position
        self.move(self.settings.value('w/pos', QPoint(200, 200)))
        self.resize(self.settings.value('w/size', QSize(1000, 1000)))
        self.ui.hist_splitter.setSizes([
            int(s) for s in self.settings.value('w/hist_splitter', [720, 360])
        ])
        self.ui.cinf_splitter.setSizes([
            int(s) for s in self.settings.value('w/cinf_splitter', [360, 360])
        ])
        self.ui.diff_splitter.setSizes([
            int(s)
            for s in self.settings.value('w/diff_splitter', [150, 1200, 230])
        ])

        # open repo dir
        open_shortcut = QShortcut(QKeySequence('Ctrl+O'), self)
        open_shortcut.activated.connect(self.open_dir)

        # set-up ui
        self.branches_model = BranchesModel()
        self.ui.tvBranches.setModel(self.branches_model)
        self.ui.tvBranches.selectionModel().selectionChanged.connect(
            self.branches_selection_changed)
        self.ui.tvBranches.resizeColumnsToContents()

        self.history_model = HistoryModel()
        self.ui.tvHistory.setModel(self.history_model)
        self.ui.tvHistory.selectionModel().selectionChanged.connect(
            self.history_selection_changed)

        self.files_model = FilesModel()
        self.ui.tvFiles.setModel(self.files_model)
        self.ui.tvFiles.selectionModel().selectionChanged.connect(
            self.files_selection_changed)

        self.ui.tvFiles.doubleClicked.connect(self.on_file_doubleclicked)

        for view in (self.ui.tvBranches, self.ui.tvHistory, self.ui.tvFiles):
            view.horizontalHeader().setSectionResizeMode(
                1, QHeaderView.Stretch)
            view.setSelectionBehavior(QAbstractItemView.SelectRows)
            view.setShowGrid(False)
            view.verticalHeader().setDefaultSectionSize(
                QApplication.font().pointSize() + 2)
            view.verticalHeader().hide()

        self.ui.teDiff.setFont(QFont('Monospace'))

        self.difftools = []

        timer = QTimer(self)
        timer.timeout.connect(self.on_timer)
        timer.start(5000)

        self.dir_name = self.settings.value('last_opened_repo', None)

        try:
            pygit2.Repository(self.dir_name)
        except Exception:  #pylint: disable=broad-except
            self.open_dir()
            return

        self.open_repo()

    def open_dir(self):
        """ show open dir dialog and open repo """
        last_dir = self.settings.value('last_fileopen_dir', '')

        fd = QFileDialog(self, 'Open .git', last_dir)
        fd.setFileMode(QFileDialog.DirectoryOnly)
        fd.setFilter(
            QDir.Filters(QDir.Dirs | QDir.Hidden | QDir.NoDot | QDir.NoDotDot))

        while True:
            if not fd.exec():
                return
            self.dir_name = fd.selectedFiles()[0]
            parent = os.path.dirname(self.dir_name)
            self.settings.setValue('last_fileopen_dir', parent)
            self.settings.setValue('last_opened_repo', self.dir_name)

            try:
                pygit2.Repository(self.dir_name)
                break
            except pygit2.GitError:
                QMessageBox(self,
                            text='Cannot open repo: ' + self.dir_name).exec()

        self.open_repo()

    def open_repo(self):
        """ called either on start or after open dialog """

        self.setWindowTitle(f'{self.dir_name} - pqgit ({VERSION})')
        self.repo = pygit2.Repository(self.dir_name)

        # remove existing files and folder from watch
        if self.fs_watch.files():
            self.fs_watch.removePaths(self.fs_watch.files())
        if self.fs_watch.directories():
            self.fs_watch.removePaths(self.fs_watch.directories())

        wd = self.repo.workdir
        self.fs_watch.addPath(wd)

        # get head tree for list of files in repo
        target = self.repo.head.target
        last_commit = self.repo[target]
        tree_id = last_commit.tree_id
        tree = self.repo[tree_id]
        # add those files and folder to watch
        self.fs_watch.addPaths([wd + o[0] for o in parse_tree_rec(tree, True)])
        # get files/folders not in repo from status
        self.fs_watch.addPaths([
            wd + p for p, f in self.repo.status().items()
            if GIT_STATUS[f] != 'I'
        ])
        # (doesn't matter some are in both lists, already monitored ones will not be added by Qt)

        # local branches
        branches = []
        selected_branch_row = 0
        for idx, b_str in enumerate(self.repo.branches.local):
            b = self.repo.branches[b_str]
            if b.is_checked_out():
                selected_branch_row = idx
            branches.append(
                Branch(name=b.branch_name, ref=b.name, c_o=b.is_checked_out()))

        # tags
        regex = re.compile('^refs/tags')
        tags = list(filter(regex.match, self.repo.listall_references()))

        branches += [Branch(name=t[10:], ref=t, c_o=False) for t in tags]

        self.branches_model.update(branches)

        idx1 = self.branches_model.index(selected_branch_row, 0)
        idx2 = self.branches_model.index(selected_branch_row,
                                         self.branches_model.columnCount() - 1)
        self.ui.tvBranches.selectionModel().select(QItemSelection(idx1, idx2),
                                                   QItemSelectionModel.Select)

        self.ui.tvHistory.resizeColumnsToContents()

    def on_timer(self):
        """ poll opened diff tools (like meld) and close temp files when finished """
        for dt in self.difftools:
            if subprocess.Popen.poll(dt.proc) is not None:
                if dt.old_f:
                    dt.old_f.close()
                if dt.new_f:
                    dt.new_f.close()
                dt.running = False

        self.difftools[:] = [dt for dt in self.difftools if dt.running]

    def on_file_changed(self, path):
        """ existing files edited """
        patch = self.files_model.patches[
            self.ui.tvFiles.selectionModel().selectedRows()[0].row()]
        if self.repo.workdir + patch.path == path:
            self.files_selection_changed()

    def on_dir_changed(self, path):
        """ file added/deleted; refresh history to show it in 'working' """
        # remember history selection
        history_ids = []
        for idx in self.ui.tvHistory.selectionModel().selectedRows():
            history_ids.append(self.history_model.commits[idx.row()].id)

        bak_path = self.files_model.patches[
            self.ui.tvFiles.selectionModel().selectedRows()[0].row()].path

        self.refresh_history()

        # restore history selection
        for i in history_ids:
            for row, c in enumerate(self.history_model.commits):
                if c.id == i:
                    idx1 = self.history_model.index(row, 0)
                    idx2 = self.history_model.index(
                        row,
                        self.history_model.columnCount() - 1)
                    self.ui.tvHistory.selectionModel().select(
                        QItemSelection(idx1, idx2), QItemSelectionModel.Select)

        # restore file selection
        if not bak_path:
            return
        for row, patch in enumerate(self.files_model.patches):
            if patch.path == bak_path:
                idx1 = self.files_model.index(row, 0)
                idx2 = self.files_model.index(
                    row,
                    self.files_model.columnCount() - 1)

                self.ui.tvFiles.selectionModel().select(
                    QItemSelection(idx1, idx2), QItemSelectionModel.Select)
                break

    def refresh_history(self):
        """ called and branch check-out (which is also called during start-up) to populate commit log """

        commits = []
        # working directory
        status = self.repo.status()
        if len(status.items()) > 0:
            commits.append(Commit('working', 'working', None, None, None,
                                  None))

        for c in self.repo.walk(self.repo.head.target,
                                pygit2.GIT_SORT_TOPOLOGICAL):
            commit = Commit(id=c.id.hex,
                            tree_id=c.tree_id.hex,
                            author=c.author,
                            dt=c.commit_time,
                            dt_offs=c.commit_time_offset,
                            message=c.message.strip())
            commits.append(commit)

        self.history_model.update(commits)
        self.ui.tvHistory.resizeColumnsToContents()

    def branches_selection_changed(self):
        """ checkout selected branch """
        selected_row = self.ui.tvBranches.selectionModel().selectedRows(
        )[0].row()
        self.repo.checkout(self.branches_model.branches[selected_row].ref,
                           strategy=pygit2.GIT_CHECKOUT_SAFE)
        self.refresh_history()

    def on_file_doubleclicked(self, index):
        """ get files contents for revisions and start diff tool """

        patch = self.files_model.patches[index.row()]
        if not patch.old_file_id:
            msg_box = QMessageBox(self)
            msg_box.setText("Nothing to compare to.")
            msg_box.exec()
            return

        old_f = tempfile.NamedTemporaryFile(
            prefix=f'old_{self.old_c_id[:7]}__')
        old_f.write(self.repo[patch.old_file_id].data)
        old_f.flush()

        new_f = None
        if patch.new_file_id:
            # compare 2 revisions
            new_f = tempfile.NamedTemporaryFile(
                prefix=f'new_{self.new_c_id[:7]}__')
            new_f.write(self.repo[patch.new_file_id].data)
            new_f.flush()
            new_f_name = new_f.name
        else:
            # compare some revision with working copy
            new_f_name = self.repo.workdir + patch.path.strip()

        proc = subprocess.Popen(
            [self.settings.value('diff_tool', 'meld'), old_f.name, new_f_name])
        self.difftools.append(Proc(proc, old_f, new_f, True))

    def history_selection_changed(self, selected):
        """ docstring """

        self.ui.teDiff.setText('')
        self.new_c_id, self.old_c_id = None, None

        selection_model = self.ui.tvHistory.selectionModel()
        selected_rows = selection_model.selectedRows()
        self.ui.teCommit.setPlainText('')

        commit = None
        fst_tid, fst_obj = None, None
        snd_tid, snd_obj = None, None

        if len(selected_rows) < 1:
            # nothing to do
            return

        if len(selected_rows) > 2:
            # don't allow more than 2 selected lines
            selection_model.select(selected, QItemSelectionModel.Deselect)
            return

        if len(selected_rows) == 1:
            # single revision selected

            commit = self.history_model.commits[selected_rows[0].row()]
            fst_tid = commit.tree_id

            if selected_rows[0].row() + 1 < self.history_model.rowCount():
                # there is a parent, get it's id to compare to it
                snd_commit = self.history_model.commits[selected_rows[0].row()
                                                        + 1]
                snd_tid = snd_commit.tree_id
                self.new_c_id = commit.id
                self.old_c_id = snd_commit.id

            # set commit details in view
            if commit.tree_id != 'working':
                text = 'Commit: ' + commit.id + '\n\n'
                text += 'Author: ' + commit.author.name + ' <' + commit.author.email + '>\n\n'
                text += commit.message + '\n'
                self.ui.teCommit.setPlainText(text)

        else:
            # 2 revisions selected
            fst_row, snd_row = tuple(
                sorted([selected_rows[0].row(), selected_rows[1].row()]))
            commit = self.history_model.commits[fst_row]
            fst_tid = commit.tree_id
            snd_commit = self.history_model.commits[snd_row]
            snd_tid = snd_commit.tree_id
            self.new_c_id = commit.id
            self.old_c_id = snd_commit.id

        if fst_tid != 'working':
            fst_obj = self.repo.revparse_single(fst_tid)

        if snd_tid:
            snd_obj = self.repo.revparse_single(snd_tid)

        diff = None
        if fst_tid == 'working':
            # diff for working directory only shows... some files; get them anyway, then insert the ones from status
            diff = self.repo.diff(
                snd_obj, None)  # regardless of snd_obj being something or None
            patches = [
                Patch(
                    p.delta.new_file.path.strip(),  #
                    p.delta.status_char(),
                    None,  # p.delta.new_file.id.hex is 'some' id, but it's somehow not ok...
                    p.delta.old_file.id.hex
                    if p.delta.old_file.id.hex.find('00000') < 0 else None,
                ) for p in diff
            ]
            inserted = [p.delta.new_file.path for p in diff]
            status = self.repo.status()
            for path, flags in status.items():
                if path not in inserted:
                    patches.append(
                        Patch(path.strip(), GIT_STATUS[flags], None, None))

        elif snd_obj:
            diff = self.repo.diff(snd_obj, fst_obj)

            patches = [
                Patch(
                    p.delta.new_file.path.strip(),  #
                    p.delta.status_char(),
                    p.delta.new_file.id.hex
                    if p.delta.new_file.id.hex.find('00000') < 0 else None,
                    p.delta.old_file.id.hex
                    if p.delta.old_file.id.hex.find('00000') < 0 else None,
                ) for p in diff
            ]

        else:
            # initial revision
            patches = [
                Patch(o[0], 'A', o[1], None) for o in parse_tree_rec(fst_obj)
            ]

        patches = sorted(patches, key=lambda p: p.path)
        self.files_model.update(patches)
        self.ui.tvFiles.resizeColumnsToContents()

    def files_selection_changed(self):
        """ show diff (or file content for new, ignored, ... files) """
        patch = self.files_model.patches[
            self.ui.tvFiles.selectionModel().selectedRows()[0].row()]

        nf_data, of_data = None, None  # new_file, old_file
        if patch.new_file_id:
            nf_data = self.repo[patch.new_file_id].data.decode('utf-8')
        if patch.old_file_id:
            of_data = self.repo[patch.old_file_id].data.decode('utf-8')

        if nf_data and of_data:
            html = _html_diff.make_file(
                fromlines=nf_data.splitlines(),  #
                tolines=of_data.splitlines(),
                fromdesc=f'old ({self.old_c_id[:7]})',
                todesc=f'new ({self.new_c_id[:7]})',
                context=True)
            self.ui.teDiff.setHtml(html)
        elif nf_data:
            self.ui.teDiff.setText(nf_data)
        elif of_data:
            if patch.status == 'M':
                # this should be working directory compared to something else
                with open(self.repo.workdir + patch.path.strip()) as f:
                    nf_data = f.read()
                html = _html_diff.make_file(
                    fromlines=nf_data.splitlines(),
                    tolines=of_data.splitlines(),
                    fromdesc=f'old ({self.old_c_id[:7]})',
                    todesc=f'new ({self.new_c_id[:7]})',
                    context=True)
                self.ui.teDiff.setHtml(html)
            else:
                self.ui.teDiff.setText(of_data)
        else:
            with open(self.repo.workdir + patch.path.strip()) as f:
                self.ui.teDiff.setPlainText(f.read())

        self.ui.diff_groupbox.setTitle(
            'Diff' if nf_data and of_data else 'File')

    def closeEvent(self, event):  # pylint: disable=invalid-name, no-self-use
        """ event handler for window closing; save settings """
        del event
        self.settings.setValue('w/pos', self.pos())
        self.settings.setValue('w/size', self.size())
        self.settings.setValue('w/hist_splitter',
                               self.ui.hist_splitter.sizes())
        self.settings.setValue('w/cinf_splitter',
                               self.ui.cinf_splitter.sizes())
        self.settings.setValue('w/diff_splitter',
                               self.ui.diff_splitter.sizes())

        # delete any left temp files
        self.on_timer()
예제 #13
0
class MainWindow(QMainWindow):

    def __init__(self, app, parent=None):
        super(MainWindow, self).__init__(parent)
        self.imagesDir = app.dir + '/images/'
        self.setWindowIcon(QIcon(self.imagesDir + 'icon.png'))
        self.path = ''

        self.settings = QSettings()
        self.lastDir = self.settings.value('lastDir', '')

        self.setMinimumWidth(540)

        self.supportedFormats = []
        for f in QImageReader.supportedImageFormats():
            self.supportedFormats.append(str(f.data(), encoding="utf-8"))

        self.fileWatcher = QFileSystemWatcher()
        self.fileWatcher.fileChanged.connect(self.fileChanged)

        # widgets
        self.showPixmapWidget = None

        self.tileWidthSpinBox = QSpinBox()
        self.tileWidthSpinBox.setValue(16)
        self.tileWidthSpinBox.setFixedWidth(50)
        self.tileWidthSpinBox.setMinimum(1)

        self.tileHeightSpinBox = QSpinBox()
        self.tileHeightSpinBox.setValue(16)
        self.tileHeightSpinBox.setFixedWidth(50)
        self.tileHeightSpinBox.setMinimum(1)

        self.paddingSpinBox = QSpinBox()
        self.paddingSpinBox.setFixedWidth(50)
        self.paddingSpinBox.setMinimum(1)

        self.transparentCheckbox = QCheckBox("Transparent")
        self.transparentCheckbox.setChecked(True)
        self.transparentCheckbox.stateChanged.connect(self.transparentChanged)

        self.backgroundColorEdit = ColorEdit()
        self.backgroundColorEdit.setEnabled(False)
        self.backgroundColorLabel = QLabel("Background color:")
        self.backgroundColorLabel.setEnabled(False)

        self.forcePotCheckBox = QCheckBox("Force PoT")
        self.forcePotCheckBox.setChecked(True)
        self.forcePotCheckBox.stateChanged.connect(self.forcePotChanged)

        self.reorderTilesCheckBox = QCheckBox("Reorder tiles")

        self.generateAndExportButton = QPushButton("Generate and export")
        self.generateAndExportButton.setFixedHeight(32)
        self.generateAndExportButton.clicked.connect(self.generateAndExportClicked)
        self.generateAndExportButton.setEnabled(False)

        self.pixmapWidget = PixmapWidget()
        self.pixmapWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.pixmapWidget.setPixmap(self.createDropTextPixmap())
        self.pixmapWidget.dropSignal.connect(self.fileDropped)
        self.pixmapWidget.setMinimumHeight(300)

        # load settings
        self.tileWidthSpinBox.setValue(int(self.settings.value('tileWidth', 16)))
        self.tileHeightSpinBox.setValue(int(self.settings.value('tileHeight', 16)))
        self.paddingSpinBox.setValue(int(self.settings.value('padding', 1)))
        self.forcePotCheckBox.setChecked(True if self.settings.value('forcePot', 'true') == 'true' else False)
        self.reorderTilesCheckBox.setChecked(True if self.settings.value('reorderTiles', 'false') == 'true' else False)
        self.transparentCheckbox.setChecked(True if self.settings.value('transparent', 'false') == 'true' else False)
        self.backgroundColorEdit.setColorText(str(self.settings.value('backgroundColor', '#FF00FF')))
        self.restoreGeometry(QByteArray(self.settings.value('MainWindow/geometry')))
        self.restoreState(QByteArray(self.settings.value('MainWindow/windowState')))

        # layout
        hl1 = QHBoxLayout()
        hl1.setContentsMargins(5, 5, 5, 5)
        hl1.addWidget(QLabel("Tile width:"))
        hl1.addSpacing(5)
        hl1.addWidget(self.tileWidthSpinBox)
        hl1.addSpacing(15)
        hl1.addWidget(QLabel("Tile height:"))
        hl1.addSpacing(5)
        hl1.addWidget(self.tileHeightSpinBox)
        hl1.addSpacing(15)
        hl1.addWidget(QLabel("Padding:"))
        hl1.addSpacing(5)
        hl1.addWidget(self.paddingSpinBox)
        hl1.addSpacing(15)
        hl1.addWidget(self.forcePotCheckBox)
        hl1.addSpacing(15)
        hl1.addWidget(self.reorderTilesCheckBox)
        hl1.addStretch()

        hl2 = QHBoxLayout()
        hl2.setContentsMargins(5, 5, 5, 5)
        hl2.addWidget(self.transparentCheckbox)
        hl2.addSpacing(15)
        hl2.addWidget(self.backgroundColorLabel)
        hl2.addSpacing(5)
        hl2.addWidget(self.backgroundColorEdit)
        hl2.addStretch()

        hl3 = QHBoxLayout()
        hl3.setContentsMargins(5, 5, 5, 5)
        hl3.addWidget(self.generateAndExportButton)

        vl = QVBoxLayout()
        vl.setContentsMargins(0, 0, 0, 0)
        vl.setSpacing(0)
        vl.addLayout(hl1)
        vl.addLayout(hl2)
        vl.addWidget(self.pixmapWidget)
        vl.addLayout(hl3)

        w = QWidget()
        w.setLayout(vl)
        self.setCentralWidget(w)

        self.setTitle()

    def setTitle(self):
        p = ' - ' + os.path.basename(self.path) if self.path else ''
        self.setWindowTitle(QCoreApplication.applicationName() + ' ' + QCoreApplication.applicationVersion() + p)

    def createDropTextPixmap(self):
        pixmap = QPixmap(481, 300)
        pixmap.fill(QColor("#333333"))
        painter = QPainter(pixmap)
        font = QFont("Arial")
        font.setPixelSize(28)
        font.setBold(True)
        fm = QFontMetrics(font)
        painter.setFont(font)
        painter.setPen(QPen(QColor("#888888"), 1))
        text = "Drop the tileset image here"
        x = (pixmap.width()-fm.width(text))/2
        y = (pixmap.height()+fm.height())/2
        painter.drawText(x, y, text)
        del painter
        return pixmap

    def fileDropped(self, path):
        path = str(path)
        name, ext = os.path.splitext(path)
        ext = ext[1:]
        if not ext in self.supportedFormats:
            QMessageBox.warning(self, "Warning", "The dropped file is not supported")
            return
        pixmap = QPixmap(path)
        if pixmap.isNull():
            QMessageBox.warning(self, "Warning", "Can't load the image")
            return
        if self.path:
            self.fileWatcher.removePath(self.path)
        self.path = path
        self.fileWatcher.addPath(self.path)
        self.pixmapWidget.setPixmap(pixmap)
        self.generateAndExportButton.setEnabled(True)
        self.setTitle()
        self.activateWindow()

    def fileChanged(self, path):
        #self.fileDropped(path)
        pass

    def transparentChanged(self):
        e = self.transparentCheckbox.isChecked()
        self.backgroundColorEdit.setEnabled(not e)
        self.backgroundColorLabel.setEnabled(not e)

    def forcePotChanged(self):
        e = self.forcePotCheckBox.isChecked()
        self.reorderTilesCheckBox.setEnabled(e)

    def generateAndExportClicked(self):

        g = Generator()
        g.tileWidth = self.tileWidthSpinBox.value()
        g.tileHeight = self.tileHeightSpinBox.value()
        g.forcePot = self.forcePotCheckBox.isChecked()
        g.isTransparent = self.transparentCheckbox.isChecked()
        g.bgColor = self.backgroundColorEdit.getColor()
        g.reorder = self.reorderTilesCheckBox.isChecked()
        g.padding = self.paddingSpinBox.value()

        target = g.create(self.pixmapWidget.pixmap);

        # export
        self.lastDir = os.path.dirname(self.path)
        targetPath = QFileDialog.getSaveFileName(self, 'Export', self.lastDir, 'PNG (*.png)')
        if targetPath:
            target.save(targetPath[0])
            showPixmap = QPixmap.fromImage(target)
            if self.showPixmapWidget:
                self.showPixmapWidget.deleteLater()
                del self.showPixmapWidget
            self.showPixmapWidget = PixmapWidget()
            self.showPixmapWidget.setWindowIcon(self.windowIcon())
            self.showPixmapWidget.setWindowTitle(os.path.basename(targetPath[0]))
            self.showPixmapWidget.resize(showPixmap.width(), showPixmap.height())
            self.showPixmapWidget.setPixmap(showPixmap)
            self.showPixmapWidget.show()

    def closeEvent(self, event):
        if self.showPixmapWidget:
            self.showPixmapWidget.close()

        # save settings
        self.settings.setValue('tileWidth', self.tileWidthSpinBox.value())
        self.settings.setValue('tileHeight', self.tileHeightSpinBox.value())
        self.settings.setValue('padding', self.paddingSpinBox.value())
        self.settings.setValue('forcePot', self.forcePotCheckBox.isChecked())
        self.settings.setValue('reorderTiles', self.reorderTilesCheckBox.isChecked())
        self.settings.setValue('transparent', self.transparentCheckbox.isChecked())
        self.settings.setValue('backgroundColor', self.backgroundColorEdit.getColor().name())
        self.settings.setValue('lastDir', self.lastDir)
        self.settings.setValue('MainWindow/geometry', self.saveGeometry())
        self.settings.setValue('MainWindow/windowState', self.saveState())

        super(MainWindow, self).closeEvent(event)
예제 #14
0
class Loader(QWidget):
    def __init__(self, parent=None):
        super(Loader, self).__init__(parent=parent)
        self.ui = Ui_Loader()
        self.ui.setupUi(self)
        self.dir = QDir(QDir.currentPath() + '/programs/')
        self.dir.setFilter(QDir.Files or QDir.NoDotAndDotDot)
        self.fs_watcher = QFileSystemWatcher(self.dir.path())
        self.fs_watcher.addPath(self.dir.path())
        self.fs_watcher.directoryChanged.connect(self.update_program_list)
        self.send_status = QProgressDialog
        self.sender = Sender
        self.serialpropertiesvalues = \
            {
                'baudrate': Serial.BAUDRATES,
                'parity': Serial.PARITIES,
                'databits': Serial.BYTESIZES,
                'stopbits': Serial.STOPBITS,
                'flowcontrol': ['NoControl', 'SoftwareControl', 'HardwareControl']
            }

        self.update_program_list()
        self.update_serial_port_list()
        # self.set_serial_port_options()

        self.ui.updateProgramListButton.clicked.connect(self.refresh)
        self.ui.programListWidget.itemSelectionChanged.connect(
            self.selection_changed)
        self.ui.sendButton.clicked.connect(self.send_program)
        self.ui.serialPortChooser.currentTextChanged.connect(
            self.selection_changed)
        self.ui.serialPortChooser.currentTextChanged.connect(save_port)
        # self.ui.baudRateInput.textChanged.connect(save_baud)
        # self.ui.parityChooser.currentTextChanged.connect(save_parity)
        # self.ui.dataBitsChooser.currentTextChanged.connect(save_databits)
        # self.ui.stopBitsChooser.currentTextChanged.connect(save_stopbits)
        # self.ui.flowControlChooser.currentTextChanged.connect(save_flowcontrol)
        self.thread_pool = QThreadPool()

    def set_serial_port_options(self):
        for key in parities.keys():
            self.ui.parityChooser.addItem(key)
        for key in bytesize.keys():
            self.ui.dataBitsChooser.addItem(key)
        for key in stopbits.keys():
            self.ui.stopBitsChooser.addItem(key)
        self.ui.flowControlChooser.addItems(flowcontrol)
        if globalSettings.contains('serialport/port'):
            self.selectpreviousvalues()
        else:
            self.saveconfig()

    def selectpreviousvalues(self):
        self.ui.serialPortChooser.setCurrentText(
            globalSettings.value('serialport/port'))
        self.ui.baudRateInput.setText(
            globalSettings.value('serialport/baudrate'))
        self.ui.parityChooser.setCurrentText(
            globalSettings.value('serialport/parity'))
        self.ui.dataBitsChooser.setCurrentText(
            globalSettings.value('serialport/databits'))
        self.ui.stopBitsChooser.setCurrentText(
            globalSettings.value('serialport/stopbits'))
        self.ui.flowControlChooser.setCurrentText(
            globalSettings.value('serialport/flowcontrol'))

    def saveconfig(self):
        save_port(self.ui.serialPortChooser.currentText())
        save_baud(self.ui.baudRateInput.text())
        save_parity(self.ui.parityChooser.currentText())
        save_databits(self.ui.dataBitsChooser.currentText())
        save_stopbits(self.ui.stopBitsChooser.currentText())
        save_flowcontrol(self.ui.flowControlChooser.currentText())

    def update_serial_port_list(self):
        self.ui.serialPortChooser.clear()
        for port in list_ports.comports():
            self.ui.serialPortChooser.addItem(port.device)

    def update_program_list(self):
        self.ui.programListWidget.clear()
        self.dir.refresh()
        self.ui.programListWidget.addItems(self.dir.entryList())
        self.ui.programListWidget.clearSelection()

    def selection_changed(self):
        if self.ui.serialPortChooser.currentText() is not None \
                and self.ui.programListWidget.currentItem() is not None:
            self.ui.sendButton.setEnabled(True)
        else:
            self.ui.sendButton.setDisabled(True)

    def refresh(self):
        self.update_program_list()
        self.update_serial_port_list()

    def send_program(self):
        selections = self.ui.programListWidget.selectedItems()
        for selection in selections:
            filename = selection.text()
            filepath = self.dir.path() + '/' + filename
            port_chosen = self.ui.serialPortChooser.currentText()
            confirm = ConfirmSend(self)
            confirm.ui.dialogLabel.setText(f'Send program \'{filename}\'?')
            confirm.exec()
            if confirm.result() == QDialog.Accepted:
                self.send_status = QProgressDialog(self)
                self.sender = Sender(
                    port_chosen, filepath,
                    globalSettings.value('serialport/baudrate'),
                    globalSettings.value('serialport/databits'),
                    globalSettings.value('serialport/parity'),
                    globalSettings.value('serialport/stopbits'),
                    globalSettings.value('serialport/flowcontrol'), self)
                self.send_status.setMaximum(self.sender.file.size())
                self.send_status.canceled.connect(self.sender.cancel)
                self.sender.signals.update_status.connect(
                    self.send_status.setValue)
                self.thread_pool.start(self.sender)
                self.send_status.exec_()
                self.send_status.deleteLater()