#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright Martin Manns
# Distributed under the terms of the GNU General Public License
# --------------------------------------------------------------------
# pyspread is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# pyspread is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with pyspread. If not, see <http://www.gnu.org/licenses/>.
# --------------------------------------------------------------------
"""
**Modal dialogs**
* :class:`DiscardChangesDialog`
* :class:`DiscardDataDialog`
* :class:`ApproveWarningDialog`
* :class:`DataEntryDialog`
* :class:`GridShapeDialog`
* :class:`SinglePageArea`
* :class:`MultiPageArea`
* :class:`CsvExportAreaDialog`
* :class:`SvgExportAreaDialog`
* :class:`PrintAreaDialog`
* (:class:`FileDialogBase`)
* :class:`FileOpenDialog`
* :class:`FileSaveDialog`
* :class:`ImageFileOpenDialog`
* :class:`CsvFileImportDialog`
* :class:`FileExportDialog`
* :class:`FindDialog`
* :class:`ChartDialog`
* :class:`CsvImportDialog`
* :class:`CsvExportDialog`
* :class:`TutorialDialog`
* :class:`ManualDialog`
* :class:`PrintPreviewDialog`
"""
from contextlib import redirect_stdout
import csv
try:
from dataclasses import dataclass
except ImportError:
from pyspread.lib.dataclasses import dataclass # Python 3.6 compatibility
from functools import partial
import io
from pathlib import Path
from typing import List, Sequence, Tuple, Union
from PyQt6.QtCore import Qt, QPoint, QSize, QEvent
from PyQt6.QtWidgets \
import (QApplication, QMessageBox, QFileDialog, QDialog, QLineEdit, QLabel,
QFormLayout, QVBoxLayout, QGroupBox, QDialogButtonBox, QSplitter,
QTextBrowser, QCheckBox, QGridLayout, QLayout, QHBoxLayout,
QPushButton, QWidget, QComboBox, QTableView, QAbstractItemView,
QPlainTextEdit, QToolBar, QMainWindow, QTabWidget, QInputDialog)
from PyQt6.QtGui \
import (QIntValidator, QImageWriter, QStandardItemModel, QStandardItem,
QValidator, QWheelEvent)
from PyQt6.QtSvgWidgets import QSvgWidget
from PyQt6.QtPrintSupport import (QPrintPreviewDialog, QPrintPreviewWidget,
QPrinter)
try:
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
except ImportError:
Figure = None
try:
from pyspread.actions import ChartDialogActions
from pyspread.toolbar import ChartTemplatesToolBar, RChartTemplatesToolBar
from pyspread.widgets import HelpBrowser, TypeMenuComboBox
from pyspread.lib.csv import sniff, csv_reader, get_header, convert
from pyspread.lib.spelltextedit import SpellTextEdit
from pyspread.settings import (TUTORIAL_PATH, MANUAL_PATH,
MPL_TEMPLATE_PATH, RPY2_TEMPLATE_PATH,
PLOT9_TEMPLATE_PATH)
except ImportError:
from actions import ChartDialogActions
from toolbar import ChartTemplatesToolBar, RChartTemplatesToolBar
from widgets import HelpBrowser, TypeMenuComboBox
from lib.csv import sniff, csv_reader, get_header, convert
from lib.spelltextedit import SpellTextEdit
from settings import (TUTORIAL_PATH, MANUAL_PATH, MPL_TEMPLATE_PATH,
RPY2_TEMPLATE_PATH, PLOT9_TEMPLATE_PATH)
[docs]
class DiscardChangesDialog:
"""Modal dialog that asks if the user wants to discard or save unsaved data
The modal dialog is shown on accessing the property choice.
"""
title = "Unsaved changes"
text = "There are unsaved changes.\nDo you want to save?"
buttons = QMessageBox.StandardButton
choices = buttons.Discard | buttons.Cancel | buttons.Save
default_choice = buttons.Save
def __init__(self, main_window: QMainWindow):
"""
:param main_window: Application main window
"""
self.main_window = main_window
@property
def choice(self) -> bool:
"""User choice
Returns True if the user confirms in a user dialog that unsaved
changes will be discarded if conformed.
Returns False if the user chooses to save the unsaved data
Returns None if the user chooses to abort the operation
"""
button_approval = QMessageBox.warning(self.main_window, self.title,
self.text, self.choices,
self.default_choice)
if button_approval == QMessageBox.StandardButton.Discard:
return True
if button_approval == QMessageBox.StandardButton.Save:
return False
[docs]
class DiscardDataDialog(DiscardChangesDialog):
"""Modal dialog that asks if the user wants to discard data"""
title = "Data to be discarded"
buttons = QMessageBox.StandardButton
choices = buttons.Discard | buttons.Cancel
default_choice = buttons.Cancel
def __init__(self, main_window: QMainWindow, text: str):
"""
:param main_window: Application main window
:param text: Message text
"""
super().__init__(main_window)
self.text = text
[docs]
class ApproveWarningDialog:
"""Modal warning dialog for approving files to be evaled
The modal dialog is shown on accessing the property choice.
"""
title = "Security warning"
text = ("You are going to approve and trust a file that you have not "
"created yourself. After proceeding, the file is executed.\n \n"
"It may harm your system as any program can. Please check all "
"cells thoroughly before proceeding.\n \n"
"Proceed and sign this file as trusted?")
buttons = QMessageBox.StandardButton
choices = buttons.No | buttons.Yes
default_choice = buttons.No
def __init__(self, parent: QWidget):
"""
:param parent: Parent widget, e.g. main window
"""
self.parent = parent
@property
def choice(self) -> bool:
"""User choice
Returns True iif the user approves leaving safe_mode.
Returns False iif the user chooses to stay in safe_mode
Returns None if the user chooses to abort the operation
"""
button_approval = QMessageBox.warning(self.parent, self.title,
self.text, self.choices,
self.default_choice)
if button_approval == self.buttons.Yes:
return True
if button_approval == self.buttons.No:
return False
[docs]
class DataEntryDialog(QDialog):
"""Modal dialog for entering multiple values"""
def __init__(self, parent: QWidget, title: str, labels: Sequence[str],
initial_data: Sequence[str] = None,
groupbox_title: str = None,
validators: Sequence[Union[QValidator, bool, None]] = None):
"""
:param parent: Parent widget, e.g. main window
:param title: Dialog title
:param labels: Labels for the values in the dialog
:param initial_data: Initial values to be displayed in the dialog
:param validators: Validators for the editors of the dialog
len(initial_data), len(validators) and len(labels) must be equal
"""
super().__init__(parent)
self.labels = labels
self.groupbox_title = groupbox_title
if initial_data is None:
self.initial_data = [""] * len(labels)
elif len(initial_data) != len(labels):
raise ValueError("Length of labels and initial_data not equal")
else:
self.initial_data = initial_data
if validators is None:
self.validators = [None] * len(labels)
elif len(validators) != len(labels):
raise ValueError("Length of labels and validators not equal")
else:
self.validators = validators
self.editors = []
layout = QVBoxLayout(self)
layout.addWidget(self.create_form())
layout.addStretch(1)
layout.addWidget(self.create_buttonbox())
self.setLayout(layout)
self.setWindowTitle(title)
self.setMinimumWidth(300)
self.setMinimumHeight(150)
@property
def data(self) -> Tuple[str]:
"""Executes the dialog and returns input as a tuple of strings
Returns None if the dialog is canceled.
"""
result = self.exec()
if result == QDialog.DialogCode.Accepted:
return tuple(editor.text() if isinstance(editor, QLineEdit)
else editor.isChecked() for editor in self.editors)
[docs]
def create_form(self) -> QGroupBox:
"""Returns form inside a QGroupBox"""
form_group_box = QGroupBox()
if self.groupbox_title:
form_group_box.setTitle(self.groupbox_title)
form_layout = QFormLayout()
for label, initial_value, validator in zip(self.labels,
self.initial_data,
self.validators):
if validator is bool:
editor = QCheckBox("")
editor.setChecked(initial_value)
else:
editor = QLineEdit(str(initial_value))
editor.setAlignment(Qt.AlignmentFlag.AlignRight)
if validator and validator is not bool:
editor.setValidator(validator)
form_layout.addRow(QLabel(label + " :"), editor)
self.editors.append(editor)
form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
form_group_box.setLayout(form_layout)
return form_group_box
[docs]
def create_buttonbox(self) -> QDialogButtonBox:
"""Returns a QDialogButtonBox with Ok and Cancel"""
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
return button_box
[docs]
class GridShapeDialog(DataEntryDialog):
"""Modal dialog for entering the number of rows, columns and tables"""
def __init__(self, parent: QWidget, shape: Tuple[int, int, int],
title: str = "Create a new Grid"):
"""
:param parent: Parent widget, e.g. main window
:param shape: Initial shape to be displayed in the dialog
"""
groupbox_title = "Grid Shape"
labels = ["Number of Rows", "Number of Columns", "Number of Tables"]
validator = QIntValidator()
validator.setBottom(1) # Do not allow negative values
validators = [validator] * len(labels)
super().__init__(parent, title, labels, shape, groupbox_title,
validators)
@property
def shape(self) -> Tuple[int, int, int]:
"""Executes the dialog and returns an rows, columns, tables
Returns None if the dialog is canceled.
"""
try:
return tuple(map(int, self.data))
except (TypeError, ValueError):
pass
[docs]
@dataclass
class SinglePageArea:
"""Holds single page area boundaries e.g. for export"""
top: int
left: int
bottom: int
right: int
[docs]
@dataclass
class MultiPageArea(SinglePageArea):
"""Holds multi page area boundaries e.g. for printing"""
first: int
last: int
[docs]
class CsvExportAreaDialog(DataEntryDialog):
"""Modal dialog for entering csv export area
Initially, this dialog is filled with the selection bounding box
if present or with the visible area of <= 1 cell is selected.
"""
groupbox_title = "Page area"
labels = ["Top", "Left", "Bottom", "Right"]
area_cls = SinglePageArea
def __init__(self, parent: QWidget, grid: QTableView, title: str):
"""
:param parent: Parent widget, e.g. main window
:param grid: The main grid widget
:param title: Dialog title
"""
self.grid = grid
self.shape = grid.model.shape
super().__init__(parent, title, self.labels, self._initial_values,
self.groupbox_title, self.validator_list)
@property
def _validator(self):
"""Returns int validator for positive numbers"""
validator = QIntValidator()
validator.setBottom(0)
return validator
@property
def _row_validator(self) -> QIntValidator:
"""Returns row validator"""
row_validator = self._validator
row_validator.setTop(self.shape[0] - 1)
return row_validator
@property
def _column_validator(self) -> QIntValidator:
"""Returns column validator"""
column_validator = self._validator
column_validator.setTop(self.shape[1] - 1)
return column_validator
@property
def validator_list(self) -> List[QIntValidator]:
"""Returns list of validators for dialog"""
return [self._row_validator, self._column_validator] * 2
@property
def _initial_values(self) -> Tuple[int, int, int, int]:
"""Returns tuple of initial values"""
grid = self.grid
shape = grid.model.shape
if grid.selection and len(grid.selected_idx) > 1:
(bb_top, bb_left), (bb_bottom, bb_right) = \
grid.selection.get_grid_bbox(shape)
else:
bb_top, bb_bottom = grid.rowAt(0), grid.rowAt(grid.height())
bb_left, bb_right = grid.columnAt(0), grid.columnAt(grid.width())
return bb_top, bb_left, bb_bottom, bb_right
@property
def area(self) -> Union[SinglePageArea, MultiPageArea]:
"""Executes the dialog and returns top, left, bottom, right
Returns None if the dialog is canceled.
"""
try:
int_data = map(int, self.data)
data = (min(self.shape[i % 2], d) for i, d in enumerate(int_data))
except (TypeError, ValueError):
return
if data is not None:
try:
return self.area_cls(*data)
except ValueError:
return
[docs]
class SvgExportAreaDialog(CsvExportAreaDialog):
"""Modal dialog for entering svg export area
Initially, this dialog is filled with the selection bounding box
if present or with the visible area of <= 1 cell is selected.
"""
groupbox_title = "SVG export area"
[docs]
class PrintAreaDialog(CsvExportAreaDialog):
"""Modal dialog for entering print area
Initially, this dialog is filled with the selection bounding box
if present or with the visible area of <= 1 cell is selected.
Initially, the current table is selected.
"""
labels = ["Top", "Left", "Bottom", "Right", "First table", "Last table"]
area_cls = MultiPageArea
@property
def _table_validator(self) -> QIntValidator:
"""Returns column validator"""
table_validator = self._validator
table_validator.setTop(self.shape[1] - 1)
return table_validator
@property
def validator_list(self) -> List[QIntValidator]:
"""Returns list of validators for dialog"""
validators = super().validator_list
validators += [self._table_validator] * 2
return validators
@property
def _initial_values(self) -> Tuple[int, int, int, int, int, int]:
"""Returns tuple of initial values"""
bb_top, bb_left, bb_bottom, bb_right = super()._initial_values
table = self.grid.table
return bb_top, bb_left, bb_bottom, bb_right, table, table
[docs]
class PreferencesDialog(DataEntryDialog):
"""Modal dialog for entering pyspread preferences"""
def __init__(self, parent: QWidget):
"""
:param parent: Parent widget, e.g. main window
"""
title = "Preferences"
groupbox_title = "Global settings"
labels = ["Signature key for files", "Cell calculation timeout [ms]",
"Frozen cell refresh period [ms]", "Number of recent files",
"Show sum in statusbar"]
self.keys = ["signature_key", "timeout", "refresh_timeout",
"max_file_history", "show_statusbar_sum"]
self.mappers = [str, int, int, int, bool]
data = [getattr(parent.settings, key) for key in self.keys]
validator = QIntValidator()
validator.setBottom(0) # Do not allow negative values
validators = [None, validator, validator, validator, bool]
super().__init__(parent, title, labels, data, groupbox_title,
validators)
@property
def data(self) -> dict:
"""Executes the dialog and returns a dict containing preferences data
Returns None if the dialog is canceled.
"""
data = super().data
if data is not None:
data_dict = {}
for key, mapper, data in zip(self.keys, self.mappers, data):
data_dict[key] = mapper(data)
return data_dict
[docs]
class CellKeyDialog(DataEntryDialog):
"""Modal dialog for entering a cell key, i.e. row, column, table"""
def __init__(self, parent: QWidget, shape: Tuple[int, int, int]):
"""
:param parent: Parent widget, e.g. main window
:param shape: Grid shape
"""
title = "Go to cell"
groupbox_title = "Cell index"
labels = ["Row", "Column", "Table"]
row_validator = QIntValidator()
row_validator.setRange(0, shape[0] - 1)
column_validator = QIntValidator()
column_validator.setRange(0, shape[1] - 1)
table_validator = QIntValidator()
table_validator.setRange(0, shape[2] - 1)
validators = [row_validator, column_validator, table_validator]
super().__init__(parent, title, labels, None, groupbox_title,
validators)
@property
def key(self) -> Tuple[int, int, int]:
"""Executes the dialog and returns rows, columns, tables
Returns None if the dialog is canceled.
"""
data = self.data
if data is None:
return
try:
return tuple(map(int, data))
except ValueError:
pass
[docs]
class FileDialogBase:
"""Base class for modal file dialogs
The chosen filename is stored in the file_path attribute
The chosen name filter is stored in the chosen_filter attribute
If the dialog is aborted then both filepath and chosen_filter are None
_get_filepath must be overloaded
"""
file_path = None
title = "Choose file"
filters_list = [
"Pyspread un-compressed (*.pysu)",
"Pyspread compressed (*.pys)"
]
selected_filter = None
@property
def filters(self) -> str:
"""Formatted filters for qt"""
return ";;".join(self.filters_list)
@property
def suffix(self) -> str:
"""Suffix for filepath"""
if self.filters_list.index(self.selected_filter):
return ".pys"
return ".pysu"
def __init__(self, main_window: QMainWindow):
"""
:param main_window: Application main window
"""
self.main_window = main_window
self.selected_filter = self.filters_list[0]
self.show_dialog()
[docs]
def show_dialog(self):
"""Sublasses must overload this method"""
raise NotImplementedError
[docs]
class FileOpenDialog(FileDialogBase):
"""Modal dialog for opening a pyspread file"""
title = "Open"
[docs]
def show_dialog(self):
"""Present dialog and update values"""
path = self.main_window.settings.last_file_input_path
self.file_path, self.selected_filter = \
QFileDialog.getOpenFileName(self.main_window, self.title,
str(path), self.filters,
self.selected_filter)
[docs]
class FileSaveDialog(FileDialogBase):
"""Modal dialog for saving a pyspread file"""
title = "Save"
[docs]
def show_dialog(self):
"""Present dialog and update values"""
path = self.main_window.settings.last_file_output_path
self.file_path, self.selected_filter = \
QFileDialog.getSaveFileName(self.main_window, self.title,
str(path), self.filters,
self.selected_filter)
[docs]
class ImageFileOpenDialog(FileDialogBase):
"""Modal dialog for inserting an image"""
title = "Insert image"
img_formats = QImageWriter.supportedImageFormats()
img_format_strings = (f"*.{fmt.data().decode()}" for fmt in img_formats)
img_format_string = " ".join(img_format_strings)
name_filter = f"Images ({img_format_string})" + ";;" \
"Scalable Vector Graphics (*.svg *.svgz)"
[docs]
def show_dialog(self):
"""Present dialog and update values"""
path = self.main_window.settings.last_file_input_path
self.file_path, self.selected_filter = \
QFileDialog.getOpenFileName(self.main_window,
self.title,
str(path),
self.name_filter)
[docs]
class CsvFileImportDialog(FileDialogBase):
"""Modal dialog for importing csv files"""
title = "Import data"
filters_list = [
"CSV file (*.*)",
]
@property
def suffix(self):
"""Do not offer suffix for filepath"""
return
[docs]
def show_dialog(self):
"""Present dialog and update values"""
path = self.main_window.settings.last_file_import_path
self.file_path, self.selected_filter = \
QFileDialog.getOpenFileName(self.main_window, self.title,
str(path), self.filters,
self.filters_list[0])
[docs]
class FileExportDialog(FileDialogBase):
"""Modal dialog for exporting csv files"""
title = "Export data"
def __init__(self, main_window: QMainWindow, filters_list: List[str]):
"""
:param main_window: Application main window
:param filters_list: List of filter strings
"""
self.filters_list = filters_list
super().__init__(main_window)
@property
def suffix(self) -> str:
"""Suffix for filepath"""
return f".{self.selected_filter.split()[0].lower()}"
[docs]
def show_dialog(self):
"""Present dialog and update values"""
path = self.main_window.settings.last_file_export_path
self.file_path, self.selected_filter = \
QFileDialog.getSaveFileName(self.main_window, self.title,
str(path), self.filters,
self.filters_list[0])
[docs]
@dataclass
class FindDialogState:
"""Dataclass for FindDialog state storage"""
pos: QPoint
case: bool
results: bool
more: bool
backward: bool
word: bool
regex: bool
start: bool
[docs]
class FindDialog(QDialog):
"""Find dialog that is launched from the main menu"""
def __init__(self, main_window: QMainWindow):
"""
:param main_window: Application main window
"""
super().__init__(main_window)
self.main_window = main_window
workflows = main_window.workflows
self._create_widgets()
self._layout()
self._order()
self.setWindowTitle("Find")
self.extension.hide()
self.more_button.toggled.connect(self.extension.setVisible)
self.find_button.clicked.connect(partial(workflows.find_dialog_on_find,
self))
# Restore state
state = self.main_window.settings.find_dialog_state
if state is not None:
self.restore(state)
[docs]
def _layout(self):
"""Find dialog layout"""
self.extension_layout = QVBoxLayout()
self.extension_layout.setContentsMargins(0, 0, 0, 0)
self.extension_layout.addWidget(self.backward_checkbox)
self.extension_layout.addWidget(self.word_checkbox)
self.extension_layout.addWidget(self.regex_checkbox)
self.extension_layout.addWidget(self.from_start_checkbox)
self.extension.setLayout(self.extension_layout)
self.text_layout = QGridLayout()
self.text_layout.addWidget(self.search_text_label, 0, 0)
self.text_layout.addWidget(self.search_text_editor, 0, 1)
self.text_layout.setColumnStretch(0, 1)
self.search_layout = QVBoxLayout()
self.search_layout.addLayout(self.text_layout)
self.search_layout.addWidget(self.case_checkbox)
self.search_layout.addWidget(self.results_checkbox)
self.main_layout = QGridLayout()
self.main_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
self.main_layout.addLayout(self.search_layout, 0, 0)
self.main_layout.addWidget(self.button_box, 0, 1)
self.main_layout.addWidget(self.extension, 1, 0, 1, 2)
self.main_layout.setRowStretch(2, 1)
self.setLayout(self.main_layout)
[docs]
def _order(self):
"""Find dialog tabOrder"""
self.setTabOrder(self.results_checkbox, self.backward_checkbox)
self.setTabOrder(self.backward_checkbox, self.word_checkbox)
self.setTabOrder(self.word_checkbox, self.regex_checkbox)
self.setTabOrder(self.regex_checkbox, self.from_start_checkbox)
[docs]
def restore(self, state):
"""Restores state from FindDialogState"""
self.move(state.pos)
self.case_checkbox.setChecked(state.case)
self.results_checkbox.setChecked(state.results)
self.more_button.setChecked(state.more)
self.backward_checkbox.setChecked(state.backward)
self.word_checkbox.setChecked(state.word)
self.regex_checkbox.setChecked(state.regex)
self.from_start_checkbox.setChecked(state.start)
# Overrides
[docs]
def closeEvent(self, event: QEvent):
"""Store state for next invocation and close
:param event: Close event
"""
state = FindDialogState(pos=self.pos(),
case=self.case_checkbox.isChecked(),
results=self.results_checkbox.isChecked(),
more=self.more_button.isChecked(),
backward=self.backward_checkbox.isChecked(),
word=self.word_checkbox.isChecked(),
regex=self.regex_checkbox.isChecked(),
start=self.from_start_checkbox.isChecked())
self.main_window.settings.find_dialog_state = state
super().closeEvent(event)
[docs]
class ReplaceDialog(FindDialog):
"""Replace dialog that is launched from the main menu"""
def __init__(self, main_window: QMainWindow):
"""
:param main_window: Application main window
"""
super().__init__(main_window)
workflows = main_window.workflows
self.setWindowTitle("Replace")
self.results_checkbox.setDisabled(True)
self.replace_text_label = QLabel("Replace with:")
self.replace_text_editor = QLineEdit()
self.replace_text_label.setBuddy(self.replace_text_editor)
self.text_layout.addWidget(self.replace_text_label, 1, 0)
self.text_layout.addWidget(self.replace_text_editor, 1, 1)
self.replace_button = QPushButton("&Replace")
self.replace_all_button = QPushButton("Replace &all")
self.button_box.addButton(self.replace_button,
QDialogButtonBox.ButtonRole.ActionRole)
self.button_box.addButton(self.replace_all_button,
QDialogButtonBox.ButtonRole.ActionRole)
self.setTabOrder(self.search_text_editor, self.replace_text_editor)
self.setTabOrder(self.more_button, self.replace_button)
p_onreplace = partial(workflows.replace_dialog_on_replace, self)
self.replace_button.clicked.connect(p_onreplace)
p_onreplaceall = partial(workflows.replace_dialog_on_replace_all, self)
self.replace_all_button.clicked.connect(p_onreplaceall)
[docs]
class ChartDialog(QDialog):
"""The chart dialog"""
def __init__(self, parent: QWidget, key: Tuple[int, int, int],
size: Tuple[int, int] = (1000, 700)):
"""
:param parent: Parent window
:param key: Target cell for chart
:param size: Initial dialog size
"""
self.key = key
if Figure is None:
raise ImportError
super().__init__(parent)
self.actions = ChartDialogActions(self)
self.chart_templates_toolbar = ChartTemplatesToolBar(self)
self.rchart_templates_toolbar = RChartTemplatesToolBar(self)
self.setWindowTitle(f"Chart dialog for cell {key}")
self.resize(*size)
self.parent = parent
self.actions = ChartDialogActions(self)
self.dialog_ui()
[docs]
def on_template(self):
"""Event handler for pressing a template toolbar button"""
chart_template_name = self.sender().data()
chart_template_code = None
tpl_paths = MPL_TEMPLATE_PATH, RPY2_TEMPLATE_PATH, PLOT9_TEMPLATE_PATH
for tpl_path in tpl_paths:
full_tpl_path = tpl_path / chart_template_name
try:
with open(full_tpl_path, encoding='utf8') as template_file:
chart_template_code = template_file.read()
except OSError:
pass
if chart_template_code is None:
return
self.editor.insertPlainText(chart_template_code)
[docs]
def dialog_ui(self):
"""Sets up dialog UI"""
msg = "Enter Python code into the editor to the left. Globals " + \
"such as X, Y, Z, S are available as they are in the grid. " + \
"The last line must result in a matplotlib figure.\n \n" + \
"Pressing Apply displays the figure or an error message in " + \
"the right area."
self.message = QTextBrowser(self)
self.message.setText(msg)
self.editor = SpellTextEdit(self)
self.splitter = QSplitter(self)
buttonbox = self.create_buttonbox()
self.splitter.addWidget(self.editor)
self.splitter.addWidget(self.message)
self.splitter.setOpaqueResize(False)
self.splitter.setSizes([9999, 9999])
# Layout
layout = QVBoxLayout(self)
toolbar_layout = QHBoxLayout()
toolbar_layout.addWidget(self.chart_templates_toolbar)
toolbar_layout.addWidget(self.rchart_templates_toolbar)
layout.addLayout(toolbar_layout)
layout.addWidget(self.splitter)
layout.addWidget(buttonbox)
self.setLayout(layout)
[docs]
def apply(self):
"""Executes the code in the dialog and updates the canvas"""
# Get current cell
key = self.parent.grid.current
code = self.editor.toPlainText()
filelike = io.StringIO()
with redirect_stdout(filelike):
figure = self.parent.grid.model.code_array._eval_cell(key, code)
stdout_str = filelike.getvalue()
if stdout_str:
stdout_str += "\n \n"
if isinstance(figure, Figure):
canvas = FigureCanvasQTAgg(figure)
self.splitter.replaceWidget(1, canvas)
try:
canvas.draw()
except Exception:
pass
elif isinstance(figure, bytes) or isinstance(figure, str):
with redirect_stdout(filelike):
if isinstance(figure, str):
figure = bytearray(figure, encoding='utf-8')
svg_widget = QSvgWidget()
self.splitter.replaceWidget(1, svg_widget)
svg_widget.renderer().load(figure)
stdout_str = filelike.getvalue()
if stdout_str:
stdout_str += "\n \n"
msg = stdout_str + f"Error:\n{figure}"
self.message.setText(msg)
else:
if isinstance(figure, Exception):
msg = stdout_str + f"Error:\n{figure}"
self.message.setText(msg)
else:
msg = stdout_str
msg_text = "Error:\n{} has type '{}', " + \
"which is no instance of {}."
msg += msg_text.format(figure, type(figure).__name__,
Figure)
self.message.setText(msg)
if self.splitter.widget(1) != self.message:
self.splitter.replaceWidget(1, self.message)
[docs]
class CsvParameterGroupBox(QGroupBox):
"""QGroupBox that holds parameter widgets for the csv import dialog"""
title = "Parameters"
quotings = "QUOTE_ALL", "QUOTE_MINIMAL", "QUOTE_NONNUMERIC", "QUOTE_NONE"
# Tooltips
encoding_widget_tooltip = "CSV file encoding"
quoting_widget_tooltip = \
"Controls when quotes should be generated by the writer and " \
"recognised by the reader."
quotechar_tooltip = \
"A one-character string used to quote fields containing special " \
"characters, such as the delimiter or quotechar, or which contain " \
"new-line characters."
delimiter_tooltip = "A one-character string used to separate fields."
escapechar_tooltip = "A one-character string used by the writer to " \
"escape the delimiter if quoting is set to QUOTE_NONE and the " \
"quotechar if doublequote is False. On reading, the escapechar " \
"removes any special meaning from the following character."
hasheader_tooltip = \
"Analyze the CSV file and treat the first row as strings if it " \
"appears to be a series of column headers."
keepheader_tooltip = "Import header labels as str in the first row"
doublequote_tooltip = \
"Controls how instances of quotechar appearing inside a field " \
"should be themselves be quoted. When True, the character is " \
"doubled. When False, the escapechar is used as a prefix to the " \
"quotechar."
skipinitialspace_tooltip = "When True, whitespace immediately following " \
"the delimiter is ignored."
# Default values that are displayed if the sniffer fails
default_quoting = "QUOTE_MINIMAL"
default_quotechar = '"'
default_delimiter = ','
def __init__(self, parent: QWidget):
"""
:param parent: Parent window
"""
super().__init__(parent)
self.parent = parent
self.default_encoding = parent.parent.settings.default_encoding
self.encodings = parent.parent.settings.encodings
self.setTitle(self.title)
self._create_widgets()
self._layout()
self.hasheader_widget.toggled.connect(self.on_hasheader_toggled)
[docs]
def _layout(self):
"""Layout widgets"""
hbox_layout = QHBoxLayout()
left_form_layout = QFormLayout()
right_form_layout = QFormLayout()
hbox_layout.addLayout(left_form_layout)
hbox_layout.addSpacing(20)
hbox_layout.addLayout(right_form_layout)
left_form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
right_form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
left_form_layout.addRow(self.encoding_label, self.encoding_widget)
left_form_layout.addRow(self.quotechar_label, self.quotechar_widget)
left_form_layout.addRow(self.delimiter_label, self.delimiter_widget)
left_form_layout.addRow(self.escapechar_label, self.escapechar_widget)
right_form_layout.addRow(self.quoting_label, self.quoting_widget)
right_form_layout.addRow(self.hasheader_label, self.hasheader_widget)
right_form_layout.addRow(self.keepheader_label, self.keepheader_widget)
right_form_layout.addRow(self.doublequote_label,
self.doublequote_widget)
right_form_layout.addRow(self.skipinitialspace_label,
self.skipinitialspace_widget)
self.setLayout(hbox_layout)
# Event handlers
[docs]
def adjust_csvdialect(self, dialect: csv.Dialect) -> csv.Dialect:
"""Adjusts csv dialect from widget settings
Note that the dialect has two extra attributes encoding and hasheader
:param dialect: Attributes class for csv reading and writing
"""
for parameter, widget in self.csv_parameter2widget.items():
if hasattr(widget, "currentText"):
value = widget.currentText()
elif hasattr(widget, "isChecked"):
value = widget.isChecked()
elif hasattr(widget, "text"):
value = widget.text()
else:
raise AttributeError(f"{widget} unsupported")
# Convert strings to csv constants
if parameter == "quoting" and isinstance(value, str):
value = getattr(csv, value)
setattr(dialect, parameter, value)
return dialect
[docs]
def set_csvdialect(self, dialect: csv.Dialect):
"""Update widgets from given csv dialect
:param dialect: Attributes class for csv reading and writing
"""
for parameter in self.csv_parameter2widget:
try:
value = getattr(dialect, parameter)
except AttributeError:
value = None
if value is not None:
widget = self.csv_parameter2widget[parameter]
if hasattr(widget, "setCurrentText"):
try:
widget.setCurrentText(value)
except TypeError:
try:
widget.setCurrentIndex(value)
except TypeError:
pass
elif hasattr(widget, "setChecked"):
widget.setChecked(bool(value))
elif hasattr(widget, "setText"):
widget.setText(value)
else:
raise AttributeError(f"{widget} unsupported")
if not self.hasheader_widget.isChecked():
self.keepheader_widget.setEnabled(False)
[docs]
class CsvTable(QTableView):
"""Table for previewing csv file content"""
no_rows = 9
def __init__(self, parent: QWidget):
"""
:param parent: Parent window
"""
super().__init__(parent)
self.parent = parent
self.comboboxes = []
self.model = QStandardItemModel(self)
self.setModel(self.model)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.verticalHeader().hide()
[docs]
def add_choice_row(self, length: int):
"""Adds row with comboboxes for digest choice
:param length: Number of columns in row
"""
item_row = map(QStandardItem, [''] * length)
self.comboboxes = [TypeMenuComboBox() for _ in range(length)]
self.model.appendRow(item_row)
for i, combobox in enumerate(self.comboboxes):
self.setIndexWidget(self.model.index(0, i), combobox)
[docs]
def fill(self, filepath: Path, dialect: csv.Dialect,
digest_types: List[str] = None):
"""Fills the csv table with values from the csv file
:param filepath: Path to csv file
:param dialect: Attributes class for csv reading and writing
:param digest_types: Names of preprocessing functions for csv values
"""
self.model.clear()
self.verticalHeader().hide()
try:
if hasattr(dialect, "encoding"):
encoding = dialect.encoding
else:
encoding = self.parent.csv_encoding
try:
with open(filepath, newline='', encoding=encoding) as csvfile:
if hasattr(dialect, 'hasheader') and dialect.hasheader:
header = get_header(csvfile, dialect)
self.model.setHorizontalHeaderLabels(header)
self.horizontalHeader().show()
else:
self.horizontalHeader().hide()
for i, row in enumerate(csv_reader(csvfile, dialect)):
if i >= self.no_rows:
break
if i == 0:
self.add_choice_row(len(row))
if digest_types is None:
item_row = map(QStandardItem, map(str, row))
else:
codes = (convert(ele, t)
for ele, t in zip(row, digest_types))
item_row = map(QStandardItem, codes)
self.model.appendRow(item_row)
except UnicodeDecodeError:
QMessageBox.warning(self.parent, "Encoding error",
f"File is not encoded in {encoding}.")
self.model.clear()
except (OSError, csv.Error) as error:
title = "CSV Import Error"
text_tpl = "Error importing csv file {path}.\n \n" +\
"{errtype}: {error}"
text = text_tpl.format(path=filepath, errtype=type(error).__name__,
error=error)
QMessageBox.warning(self.parent, title, text)
[docs]
def get_digest_types(self) -> List[str]:
"""Returns list of digest types from comboboxes"""
try:
return [cbox.currentText() for cbox in self.comboboxes]
except RuntimeError:
return []
[docs]
def update_comboboxes(self, digest_types: List[str]):
"""Updates the cono boxes to show digest_types
:param digest_types: Names of preprocessing functions for csv values
"""
for combobox, digest_type in zip(self.comboboxes, digest_types):
combobox.setCurrentText(digest_type)
[docs]
class CsvImportDialog(QDialog):
"""Modal dialog for importing csv files"""
title = "CSV import"
def __init__(self, parent: QWidget, filepath: Path,
digest_types: List[str] = None):
"""
:param parent: Parent window
:param filepath: Path to csv file
:param digest_types: Names of preprocessing functions for csv values
"""
super().__init__(parent)
self.parent = parent
self.filepath = filepath
self.digest_types = digest_types
self.sniff_size = parent.settings.sniff_size
self.csv_encoding = 'utf-8'
self.dialect = None
self.setWindowTitle(self.title)
self.parameter_groupbox = CsvParameterGroupBox(self)
self.csv_table = CsvTable(self)
layout = QVBoxLayout(self)
layout.addWidget(self.parameter_groupbox)
layout.addWidget(self.csv_table)
layout.addWidget(self.create_buttonbox())
self.setLayout(layout)
self.reset()
[docs]
def _sniff_dialect(self):
"""Sniff the dialect of self.filepath`"""
try:
return sniff(self.filepath, self.sniff_size, self.csv_encoding)
except UnicodeError:
self.csv_encoding, ok = QInputDialog().getItem(
self, f"{self.filepath} not encoded in utf-8",
f"Encoding of {self.filepath}",
self.parent.settings.encodings)
if ok:
return self._sniff_dialect()
except (OSError, csv.Error) as error:
title = "CSV Import Error"
text = f"Error sniffing csv file {self.filepath}.\n \n" + \
f"{type(error).__name__}: {error}"
QMessageBox.warning(self.parent, title, text)
# Button event handlers
[docs]
def reset(self):
"""Button event handler, resets parameter_groupbox and csv_table"""
dialect = self._sniff_dialect()
if dialect is None:
return
self.parameter_groupbox.set_csvdialect(dialect)
self.csv_table.fill(self.filepath, dialect)
if self.digest_types is not None:
self.csv_table.update_comboboxes(self.digest_types)
[docs]
def apply(self):
"""Button event handler, applies parameters to csv_table"""
sniff_dialect = self._sniff_dialect()
if sniff_dialect is None:
return
try:
dialect = self.parameter_groupbox.adjust_csvdialect(sniff_dialect)
except AttributeError as error:
title = "CSV Import Error"
text_tpl = "Error setting dialect for csv file {path}.\n \n" +\
"{errtype}: {error}"
text = text_tpl.format(path=self.filepath,
errtype=type(error).__name__, error=error)
QMessageBox.warning(self.parent, title, text)
return
digest_types = self.csv_table.get_digest_types()
self.csv_table.fill(self.filepath, dialect, digest_types)
self.csv_table.update_comboboxes(digest_types)
[docs]
def accept(self):
"""Button event handler, starts csv import"""
sniff_dialect = self._sniff_dialect()
if sniff_dialect is None:
return
try:
dialect = self.parameter_groupbox.adjust_csvdialect(sniff_dialect)
except AttributeError as error:
title = "CSV Import Error"
text_tpl = "Error setting dialect for csv file {path}.\n \n" +\
"{errtype}: {error}"
text = text_tpl.format(path=self.filepath,
errtype=type(error).__name__, error=error)
QMessageBox.warning(self.parent, title, text)
self.reject()
return
self.digest_types = self.csv_table.get_digest_types()
self.dialect = dialect
super().accept()
[docs]
class CsvExportDialog(QDialog):
"""Modal dialog for exporting csv files"""
title = "CSV export"
maxrows = 10
def __init__(self, parent: QWidget, csv_area: SinglePageArea):
"""
:param parent: Parent window
:param csv_area: Grid area to be exported
"""
super().__init__(parent)
self.parent = parent
self.csv_area = csv_area
self.dialect = self.default_dialect
self.setWindowTitle(self.title)
self.parameter_groupbox = CsvParameterGroupBox(self)
self.csv_preview = QPlainTextEdit(self)
self.csv_preview.setReadOnly(True)
layout = QVBoxLayout(self)
layout.addWidget(self.parameter_groupbox)
layout.addWidget(self.csv_preview)
layout.addWidget(self.create_buttonbox())
self.setLayout(layout)
self.reset()
@property
def default_dialect(self) -> csv.Dialect:
"""Default dialect for export based on excel-tab"""
dialect = csv.excel
dialect.encoding = "utf-8"
dialect.hasheader = False
return dialect
[docs]
def reset(self):
"""Button event handler, resets parameter_groupbox and csv_preview"""
self.parameter_groupbox.set_csvdialect(self.default_dialect)
self.csv_preview.clear()
[docs]
def apply(self):
"""Button event handler, applies parameters to csv_preview"""
top = self.csv_area.top
left = self.csv_area.left
bottom = self.csv_area.bottom
right = self.csv_area.right
table = self.parent.grid.table
bottom = min(bottom-top, self.maxrows-1) + top
code_array = self.parent.grid.model.code_array
csv_data = code_array[top: bottom + 1, left: right + 1, table]
adjust_csvdialect = self.parameter_groupbox.adjust_csvdialect
dialect = adjust_csvdialect(self.default_dialect)
str_io = io.StringIO()
writer = csv.writer(str_io, dialect=dialect)
writer.writerows(csv_data)
self.csv_preview.setPlainText(str_io.getvalue())
[docs]
def accept(self):
"""Button event handler, starts csv import"""
adjust_csvdialect = self.parameter_groupbox.adjust_csvdialect
self.dialect = adjust_csvdialect(self.default_dialect)
super().accept()
[docs]
class TutorialDialog(QDialog):
"""Dialog for browsing the pyspread tutorial"""
window_title = "pyspread tutorial"
size_hint = 1000, 800
path: Path = TUTORIAL_PATH / 'tutorial.md'
def __init__(self, parent: QWidget):
"""
:param parent: Parent window
"""
super().__init__(parent)
self._create_widgets()
self._layout()
[docs]
def _layout(self):
"""Dialog layout management"""
self.setWindowTitle(self.window_title)
layout = QHBoxLayout()
layout.addWidget(self.browser)
self.setLayout(layout)
# Overrides
[docs]
def sizeHint(self) -> QSize:
"""QDialog.sizeHint override"""
return QSize(*self.size_hint)
[docs]
class ManualDialog(TutorialDialog):
"""Dialog for browsing the pyspread manual"""
window_title = "pyspread manual"
size_hint = 1000, 800
title2path = {
"Overview": MANUAL_PATH / 'overview.md',
"Concepts": MANUAL_PATH / 'basic_concepts.md',
"Workspace": MANUAL_PATH / 'workspace.md',
"File": MANUAL_PATH / 'file_menu.md',
"Edit": MANUAL_PATH / 'edit_menu.md',
"View": MANUAL_PATH / 'view_menu.md',
"Format": MANUAL_PATH / 'format_menu.md',
"Macro": MANUAL_PATH / 'macro_menu.md',
"Advanced topics": MANUAL_PATH / 'advanced_topics.md',
}
[docs]
def _layout(self):
"""Dialog layout management"""
self.setWindowTitle(self.window_title)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
layout.addWidget(self.tabbar)
[docs]
class PrintPreviewDialog(QPrintPreviewDialog):
"""Adds Mouse wheel functionality"""
def __init__(self, printer: QPrinter):
"""
:param printer: Target printer
"""
super().__init__(printer)
self.toolbar = self.findChildren(QToolBar)[0]
self.actions = self.toolbar.actions()
self.widget = self.findChildren(QPrintPreviewWidget)[0]
self.combo_zoom = self.toolbar.widgetForAction(self.actions[3])
[docs]
def wheelEvent(self, event: QWheelEvent):
"""Overrides mouse wheel event handler
:param event: Mouse wheel event
"""
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.KeyboardModifier.ControlModifier:
if event.angleDelta().y() > 0:
zoom_factor = self.widget.zoomFactor() / 1.1
else:
zoom_factor = self.widget.zoomFactor() * 1.1
self.widget.setZoomFactor(zoom_factor)
self.combo_zoom.setCurrentText(str(round(zoom_factor*100, 1))+"%")
else:
super().wheelEvent(event)