kn-bibs/dotplot

View on GitHub
dotplot/gui/main_window.py

Summary

Maintainability
A
3 hrs
Test Coverage
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMainWindow
from PyQt5.QtWidgets import QAction
from PyQt5.QtWidgets import QMessageBox
from PyQt5.QtWidgets import QWidget
from PyQt5.QtWidgets import QFileDialog
from PyQt5.QtWidgets import QPushButton
from PyQt5.QtWidgets import QVBoxLayout
from PyQt5.QtWidgets import QHBoxLayout
from PyQt5.QtWidgets import QLabel
from PyQt5.QtWidgets import QSplitter
from ..dotplot import Dotplot
from ..sequence import DownloadFailed
from ..sequence import Sequence
from .chooser import Chooser
from .options import OptionPanel


class SequenceSelector(QHBoxLayout):

    def __init__(self, window, seq_id, current_sequence_name=None):
        """Creates and handles widgets for a file selection."""
        super().__init__()
        from PyQt5.QtWidgets import QToolButton

        self.window = window
        self.seq_id = seq_id

        if not current_sequence_name and len(self.window.sequences) >= seq_id:
            sequence = self.window.sequences[seq_id - 1]
            if sequence:
                current_sequence_name = self.window.sequences[seq_id - 1].name

        self.current_sequence_indicator = QLabel(window)
        self.current_sequence_indicator.setText(
            current_sequence_name or 'Not selected'
        )
        self.select_btn = QPushButton('Select sequence %s' % seq_id)
        self.select_btn.clicked.connect(self.callback_file)

        self.more_btn = QToolButton()
        self.more_btn.setArrowType(Qt.DownArrow)
        self.more_btn.clicked.connect(self.callback_more)

        self.btn_box = QHBoxLayout()
        self.btn_box.setContentsMargins(0, 0, 0, 0)
        self.btn_box.setSpacing(0)
        self.btn_box.addWidget(self.select_btn)
        self.btn_box.addWidget(self.more_btn)

        self.addWidget(self.current_sequence_indicator)
        self.addLayout(self.btn_box)

    def select_sequence_dialog(self):
        """Invoke dialog window allowing to choose a sequence file.

        A tuple (file_name, file_type) will be returned.
        If nothing was selected the tuple will be have two empty strings.
        """
        selected_file_data = QFileDialog.getOpenFileName(
            self.window,
            'Open file',
            '',  # use the last (or default) directory. It HAS to be str
            'Fasta files (*.fa *.fasta);;Plain text file (*.txt);;All files (*)',
            None,
            QFileDialog.DontUseNativeDialog
        )

        return selected_file_data

    def load_sequence(self, source_method, name, *source_specific_parameters):
        """Create and load sequence from given source, using specified parameters."""
        from os.path import basename
        constructor = getattr(Sequence, source_method)
        sequence = constructor(*source_specific_parameters)
        self.window.sequences[self.seq_id - 1] = sequence
        self.window.set_status('Sequence "%s" loaded successfully' % name)
        self.current_sequence_indicator.setText(
            '%s (%s)' % (sequence.name, basename(name))
        )
        return True

    def callback_file(self):
        file_name, file_type = self.select_sequence_dialog()

        if not file_name:
            return

        with open(file_name, 'r') as file_handle:

            if file_name.endswith('.fa') or file_name.endswith('.fasta'):
                return self.load_sequence('from_fasta_file', file_name, file_handle)
            elif file_name.endswith('.txt'):
                return self.load_sequence('from_text_file', file_name, file_handle)
            else:
                try:
                    extension = file_name.split('.')[1]
                    self.window.set_status(
                        'Unknown file extension "%s" (of file "%s")'
                        %
                        (extension, file_name)
                    )
                except IndexError:
                    self.window.set_status(
                        'No file extension detected in "%s" file name'
                        %
                        file_name
                    )
                return False

    def callback_more(self):
        result = Chooser.choose()
        if not result:  # chooser does not guarantee to return a tuple
            return
        database, sequence_name = result
        self.window.set_status('Sequence download in progress')
        try:
            self.load_sequence(
                'from_' + database,
                sequence_name + ' (' + database + ')',
                sequence_name,
                )
            self.window.set_status('Sequence downloaded successfully')
        except DownloadFailed as e:
            self.window.set_status(e.message)


class MainWindow(QMainWindow):
    def __init__(self, args):
        super().__init__()

        self.sequences = args.parsed_sequences
        self.args = args

        self.use_matplotlib = args.drawer.method == 'matplotlib'

        self.init_ui()

        if self.are_sequences_loaded():
            self.new_plot()

    def set_status(self, text):
        self.statusBar().showMessage(text)

    def are_sequences_loaded(self):
        """Sequences are correctly loaded if both file handles are not empty"""
        return (
            len(self.sequences) >= 2 and
            self.sequences[0] and
            self.sequences[1]
        )

    def init_ui(self):
        """Initialize all GUI elements and show window."""

        self.set_status('Welcome')
        self.create_menus()

        canvas_box = self.create_canvas()
        sequence_form = self.create_sequence_form()

        # let's have the sequence form over the canvas.
        vbox = QVBoxLayout()
        vbox.addLayout(sequence_form, stretch=0)
        vbox.setAlignment(Qt.AlignTop)

        vbox.addLayout(canvas_box, stretch=1)

        splitter = QSplitter(Qt.Horizontal)

        options = OptionPanel(self.args)

        for layout in [vbox, options]:
            widget = QWidget()
            widget.setLayout(layout)
            splitter.addWidget(widget)

        self.setCentralWidget(splitter)

        self.resize(600, 600)
        self.setWindowTitle('Dotplot')
        self.show()

    def new_plot(self):
        """Create and display a new plot, using current sequences and args."""

        # we require sequences to be present here!
        if not self.are_sequences_loaded():
            QMessageBox.information(
                self,
                'Sequences not selected',
                'Please, select sequences first :)'
            )
            return False

        # make new dotplot
        self.set_status('Creating a plot')
        dotplot = Dotplot(
            self.sequences,
            self.args.plotter,
            self.args.drawer
        )

        dotplot.make_plot()

        self.display_plot(dotplot)
        self.set_status('Plot created successfully')
        return True

    def select_save_file_dialog(self):
        """Supported formats: eps, pdf, pgf, png, ps, raw, rgba, svg, svgz."""
        extensions = {'PNG file (*.png)': '.png', 'PDF file (*.pdf)': '.pdf',
                      'SVG files (*.svg, *.svgz)': '.svg', 'All files (*)': ''}
        extensions_string = ';;'.join(extensions.keys())
        file_data = QFileDialog.getSaveFileName(
            self,
            'Choose a directory',
            '',  # use the last (or default) directory. It HAS to be str
            extensions_string,
            None,
            QFileDialog.DontUseNativeDialog)
        file_name = file_data[0]
        if file_name == '':
            return
        extension = extensions[file_data[1]]
        if extension not in file_name and '.' not in file_name:
            file_name += extension
        self.canvas.save_file(file_name)

    def create_sequence_form(self):
        """Create whole panel for sequence selection."""
        sequence_1_selector = SequenceSelector(self, 1)
        sequence_2_selector = SequenceSelector(self, 2)
        plot_button = QPushButton('Plot!')
        plot_button.clicked.connect(self.new_plot)

        data_form = QVBoxLayout()
        data_form.addLayout(sequence_1_selector)
        data_form.addLayout(sequence_2_selector)
        data_form.addWidget(plot_button)

        return data_form

    def create_canvas(self):
        """Make widgets where the drawing will take place.

        Currently TextEdit is used - only temporarily ;)
        """
        self.canvas_box = QVBoxLayout()
        savebutton = QPushButton('Save plot to file')
        savebutton.clicked.connect(self.select_save_file_dialog)

        if self.use_matplotlib:
            from ..figures_plot import MyFigure
            self.canvas = MyFigure()
        else:
            from PyQt5.QtGui import QFont
            text_area = QLabel()
            font = QFont('Monospace', 8, QFont.TypeWriter)
            text_area.setFont(font)
            text_area.setAlignment(Qt.AlignCenter)
            text_area.setStyleSheet('font-family:Monospace,Courier')
            self.canvas = text_area

        self.canvas_box.addWidget(self.canvas)
        self.canvas_box.addWidget(savebutton)

        return self.canvas_box

    def create_menus(self):
        """Create menu entries and appropriate actions."""

        menu_bar = self.menuBar()

        action_exit = QAction(
            'E&xit', self, shortcut='Ctrl+Q',
            statusTip='Exit the application', triggered=self.close
        )

        action_save = QAction(
            'Save plot to file', self, shortcut='Ctrl+S',
            triggered=self.select_save_file_dialog
        )

        file_menu = menu_bar.addMenu('&File')
        file_menu.addAction(action_exit)
        file_menu.addAction(action_save)

        action_about = QAction(
            '&About', self,
            statusTip='More about this app', triggered=self.about
        )

        action_tutorial = QAction(
            '&Tutorial', self,
            statusTip='Here should be your tutorial', triggered=self.tutorial
        )

        help_menu = menu_bar.addMenu('&Help')
        help_menu.addAction(action_about)
        help_menu.addAction(action_tutorial)

    def about(self):
        """Show modal window with description of this program."""
        QMessageBox.about(
            self,
            'About Dotplot',
            'There are <i>many</i> programs that attempt to create dotplots already. '
            'Unfortunately most of these programs was created long time ago and written '
            'in old versions of Java. <p>This Python3 package will allow new generations '
            'of bioinformaticians to generate dotplots much easier.</p>'
        )

    def tutorial(self):
        """Show modal window with tutorial."""
        QMessageBox.about(
            self,
            'Tutorial',
            'Microsatellites (2-5 base pairs) and minisatellies (10-50 base pairs), repeated 10-50 times are highly '
            'mutable genome regions of low complexity; they are present in telomeres. '
            'They are used in researching <s>similarity</s> between genomes.'
            '<p>Any longer section suggests a least some local similarity of studied structures. '
            'If we observe many indel regions, inversions, dotted lines while comparing sequences of two organisms, '
            'it suggests that they are related. </p>'
        )

    def display_plot(self, dotplot):
        """Display provided plot from given dotplot instance."""

        if self.use_matplotlib:
            self.canvas.reset()
            dotplot.draw(self.canvas.main_plot, self.sequences)
            self.canvas.draw()
        else:
            plot_text = dotplot.draw()
            geometry = self.frameGeometry()
            height = geometry.height() - 100
            width = geometry.width() - 100
            size = round(
                min(
                    width / len(self.sequences[0]) * 2,
                    height / len(self.sequences[1])
                ) / 2
            )
            if size == 0:
                size = 1
            self.canvas.setStyleSheet('font-size:%spx' % size)
            self.canvas.setText(plot_text)