Source code for pyspread

#!/usr/bin/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/>.
# --------------------------------------------------------------------

"""

pyspread
========

- Main Python spreadsheet application
- Run this script to start the application.

**Provides**

* MainApplication: Initial command line operations and application launch
* :class:`MainWindow`: Main windows class

"""

import os
import sys
import traceback

from PyQt5.QtCore import Qt, pyqtSignal, QEvent, QTimer, QRectF
from PyQt5.QtWidgets import (QWidget, QMainWindow, QApplication,
                             QMessageBox, QDockWidget, QUndoStack, QVBoxLayout,
                             QStyleOptionViewItem, QSplitter)
try:
    from PyQt5.QtSvg import QSvgWidget
except ImportError:
    QSvgWidget = None
from PyQt5.QtGui import QColor, QFont, QPalette, QPainter
from PyQt5.QtPrintSupport import QPrinter, QPrintDialog

try:
    from pyspread.__init__ import VERSION, APP_NAME
    from pyspread.cli import PyspreadArgumentParser
    from pyspread.settings import Settings, WEB_URL
    from pyspread.icons import Icon, IconPath
    from pyspread.grid import Grid, TableChoice
    from pyspread.grid_renderer import painter_save
    from pyspread.entryline import Entryline
    from pyspread.menus import MenuBar
    from pyspread.toolbar import (MainToolBar, FindToolbar, FormatToolbar,
                                  MacroToolbar)
    from pyspread.actions import MainWindowActions
    from pyspread.workflows import Workflows
    from pyspread.widgets import Widgets
    from pyspread.dialogs import (ApproveWarningDialog, PreferencesDialog,
                                  ManualDialog, TutorialDialog,
                                  PrintAreaDialog, PrintPreviewDialog)
    from pyspread.installer import DependenciesDialog
    from pyspread.panels import MacroPanel
    from pyspread.lib.hashing import genkey
    from pyspread.model.model import CellAttributes
except ImportError:
    from __init__ import VERSION, APP_NAME
    from cli import PyspreadArgumentParser
    from settings import Settings, WEB_URL
    from icons import Icon, IconPath
    from grid import Grid, TableChoice
    from grid_renderer import painter_save
    from entryline import Entryline
    from menus import MenuBar
    from toolbar import MainToolBar, FindToolbar, FormatToolbar, MacroToolbar
    from actions import MainWindowActions
    from workflows import Workflows
    from widgets import Widgets
    from dialogs import (ApproveWarningDialog, PreferencesDialog, ManualDialog,
                         TutorialDialog, PrintAreaDialog, PrintPreviewDialog)
    from installer import DependenciesDialog
    from panels import MacroPanel
    from lib.hashing import genkey
    from model.model import CellAttributes


LICENSE = "GNU GENERAL PUBLIC LICENSE Version 3"

os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)


[docs]class MainWindow(QMainWindow): """Pyspread main window""" gui_update = pyqtSignal(dict) def __init__(self, filepath: str = None, default_settings: bool = False): """ :param filepath: File path for inital file to be opened :param default_settings: Ignore stored `QSettings` and use defaults """ super().__init__() self._loading = True # For initial loading of pyspread self.prevent_updates = False # Prevents setData updates in grid self.settings = Settings(self, reset_settings=default_settings) self.workflows = Workflows(self) self.undo_stack = QUndoStack(self) self.refresh_timer = QTimer() self._init_widgets() self.main_window_actions = MainWindowActions(self) self.main_window_toolbar_actions = MainWindowActions(self, shortcuts=False) self._init_window() self._init_toolbars() self.settings.restore() if self.settings.signature_key is None: self.settings.signature_key = genkey() # Print area for print requests self.print_area = None # Update recent files in the file menu self.menuBar().file_menu.history_submenu.update() # Update toolbar toggle checkboxes self.update_action_toggles() # Update the GUI so that everything matches the model cell_attributes = self.grid.model.code_array.cell_attributes attributes = cell_attributes[self.grid.current] self.on_gui_update(attributes) self._last_focused_grid = self.grid self._loading = False self._previous_window_state = self.windowState() # Open initial file if provided by the command line if filepath is not None: if self.workflows.filepath_open(filepath): self.workflows.update_main_window_title() else: msg = "File '{}' could not be opened.".format(filepath) self.statusBar().showMessage(msg)
[docs] def _init_window(self): """Initialize main window components""" self.setWindowTitle(APP_NAME) self.setWindowIcon(Icon.pyspread) # Safe mode widget self.safe_mode_widget = QSvgWidget(str(IconPath.safe_mode), self.statusBar()) msg = "%s is in safe mode.\nExpressions are not evaluated." % APP_NAME self.safe_mode_widget.setToolTip(msg) self.statusBar().addPermanentWidget(self.safe_mode_widget) self.safe_mode_widget.hide() # Selection mode widget self.selection_mode_widget = QSvgWidget(str(IconPath.selection_mode), self.statusBar()) msg = "Selection mode active. Cells cannot be edited.\n" + \ "Selecting cells adds relative references into the entry " + \ "line. Additionally pressing `Meta` switches to absolute " + \ "references.\nEnd selection mode by clicking into the entry " + \ "line or with `Esc` when focusing the grid." self.selection_mode_widget.setToolTip(msg) self.statusBar().addPermanentWidget(self.selection_mode_widget) self.selection_mode_widget.hide() # Disable the approve fiel menu button self.main_window_actions.approve.setEnabled(False) self.setMenuBar(MenuBar(self))
[docs] def resizeEvent(self, event: QEvent): """Overloaded, aborts on self._loading :param event: Resize event """ if self._loading: return super(MainWindow, self).resizeEvent(event)
[docs] def closeEvent(self, event: QEvent = None): """Overloaded, allows saving changes or canceling close :param event: Any QEvent """ if event: event.ignore() self.workflows.file_quit() # has @handle_changed_since_save decorator
[docs] def _init_widgets(self): """Initialize widgets""" self.widgets = Widgets(self) self.entry_line = Entryline(self) self.vsplitter = QSplitter(Qt.Vertical, self) self.hsplitter_1 = QSplitter(Qt.Horizontal, self) self.hsplitter_2 = QSplitter(Qt.Horizontal, self) # Set up the table choice first _no_tables = self.settings.shape[2] self.table_choice = TableChoice(self, _no_tables) # We have one main view that is used as default view self.grid = Grid(self) # Further views of the grid self.grid_2 = Grid(self, self.grid.model) self.grid_3 = Grid(self, self.grid.model) self.grid_4 = Grid(self, self.grid.model) self.grids = [self.grid, self.grid_2, self.grid_3, self.grid_4] self.macro_panel = MacroPanel(self, self.grid.model.code_array) self.main_panel = QWidget(self) self.entry_line_dock = QDockWidget("Entry Line", self) self.entry_line_dock.setObjectName("Entry Line Panel") self.entry_line_dock.setWidget(self.entry_line) self.addDockWidget(Qt.TopDockWidgetArea, self.entry_line_dock) self.resizeDocks([self.entry_line_dock], [10], Qt.Horizontal) self.macro_dock = QDockWidget("Macros", self) self.macro_dock.setObjectName("Macro Panel") self.macro_dock.setWidget(self.macro_panel) self.addDockWidget(Qt.RightDockWidgetArea, self.macro_dock) self._layout() self.entry_line_dock.installEventFilter(self) self.macro_dock.installEventFilter(self) QApplication.instance().focusChanged.connect(self.on_focus_changed) self.gui_update.connect(self.on_gui_update) self.refresh_timer.timeout.connect(self.on_refresh_timer) # Connect widgets only to first grid self.widgets.text_color_button.colorChanged.connect( self.grid.on_text_color) self.widgets.background_color_button.colorChanged.connect( self.grid.on_background_color) self.widgets.line_color_button.colorChanged.connect( self.grid.on_line_color) self.widgets.font_combo.fontChanged.connect(self.grid.on_font) self.widgets.font_size_combo.fontSizeChanged.connect( self.grid.on_font_size)
[docs] def _layout(self): """Layouts for main window""" self.central_layout = QVBoxLayout(self.main_panel) self.central_layout.addWidget(self.vsplitter) self.central_layout.addWidget(self.grid.table_choice) self.vsplitter.addWidget(self.hsplitter_1) self.vsplitter.addWidget(self.hsplitter_2) self.hsplitter_1.addWidget(self.grid) self.hsplitter_1.addWidget(self.grid_2) self.hsplitter_2.addWidget(self.grid_3) self.hsplitter_2.addWidget(self.grid_4) self.vsplitter.setSizes([1, 0]) self.hsplitter_1.setSizes([1, 0]) self.hsplitter_2.setSizes([1, 0]) self.main_panel.setLayout(self.central_layout) self.setCentralWidget(self.main_panel)
[docs] def eventFilter(self, source: QWidget, event: QEvent) -> bool: """Overloaded event filter for handling QDockWidget close events Updates the menu if the macro panel is closed. :param source: Source widget of event :param event: Any QEvent """ if event.type() == QEvent.Close and isinstance(source, QDockWidget): if source.windowTitle() == "Macros": self.main_window_actions.toggle_macro_dock.setChecked(False) elif source.windowTitle() == "Entry Line": self.main_window_actions.toggle_entry_line_dock.setChecked( False) return super().eventFilter(source, event)
[docs] def _init_toolbars(self): """Initialize the main window toolbars""" self.main_toolbar = MainToolBar(self) self.macro_toolbar = MacroToolbar(self) self.find_toolbar = FindToolbar(self) self.format_toolbar = FormatToolbar(self) self.addToolBar(self.main_toolbar) self.addToolBar(self.macro_toolbar) self.addToolBar(self.find_toolbar) self.addToolBarBreak() self.addToolBar(self.format_toolbar)
[docs] def update_action_toggles(self): """Updates the toggle menu check states""" actions = self.main_window_actions maintoolbar_visible = self.main_toolbar.isVisibleTo(self) actions.toggle_main_toolbar.setChecked(maintoolbar_visible) macrotoolbar_visible = self.macro_toolbar.isVisibleTo(self) actions.toggle_macro_toolbar.setChecked(macrotoolbar_visible) formattoolbar_visible = self.format_toolbar.isVisibleTo(self) actions.toggle_format_toolbar.setChecked(formattoolbar_visible) findtoolbar_visible = self.find_toolbar.isVisibleTo(self) actions.toggle_find_toolbar.setChecked(findtoolbar_visible) entryline_visible = self.entry_line_dock.isVisibleTo(self) actions.toggle_entry_line_dock.setChecked(entryline_visible) macrodock_visible = self.macro_dock.isVisibleTo(self) actions.toggle_macro_dock.setChecked(macrodock_visible)
@property def focused_grid(self): """Returns grid with focus or self if none has focus""" try: return self._last_focused_grid except AttributeError: return self.grid @property def safe_mode(self) -> bool: """Returns safe_mode state. In safe_mode cells are not evaluated.""" return self.grid.model.code_array.safe_mode @safe_mode.setter def safe_mode(self, value: bool): """Sets safe mode. This triggers the safe_mode icon in the statusbar. If safe_mode changes from True to False then caches are cleared and macros are executed. :param value: Safe mode """ if self.grid.model.code_array.safe_mode == bool(value): return self.grid.model.code_array.safe_mode = bool(value) if value: # Safe mode entered self.safe_mode_widget.show() # Enable approval menu entry self.main_window_actions.approve.setEnabled(True) else: # Safe_mode disabled self.safe_mode_widget.hide() # Disable approval menu entry self.main_window_actions.approve.setEnabled(False) # Clear result cache self.grid.model.code_array.result_cache.clear() # Execute macros self.macro_panel.on_apply()
[docs] def on_print(self): """Print event handler""" # Create printer printer = QPrinter(mode=QPrinter.HighResolution) # Get print area self.print_area = PrintAreaDialog(self, self.grid, title="Print area").area if self.print_area is None: return # Create print dialog dialog = QPrintDialog(printer, self) if dialog.exec_() == QPrintDialog.Accepted: self.on_paint_request(printer)
[docs] def on_preview(self): """Print preview event handler""" # Create printer printer = QPrinter(mode=QPrinter.HighResolution) # Get print area self.print_area = PrintAreaDialog(self, self.grid, title="Print area").area if self.print_area is None: return # Create print preview dialog dialog = PrintPreviewDialog(printer) dialog.paintRequested.connect(self.on_paint_request) dialog.exec_()
[docs] def on_paint_request(self, printer: QPrinter): """Paints to printer :param printer: Target printer """ painter = QPainter(printer) option = QStyleOptionViewItem() painter.setRenderHints(QPainter.SmoothPixmapTransform | QPainter.SmoothPixmapTransform) page_rect = printer.pageRect() rows = list(self.workflows.get_paint_rows(self.print_area.top, self.print_area.bottom)) columns = list(self.workflows.get_paint_columns(self.print_area.left, self.print_area.right)) tables = list(self.workflows.get_paint_tables(self.print_area.first, self.print_area.last)) if not all((rows, columns, tables)): return old_table = self.grid.table for i, table in enumerate(tables): self.grid.table = table zeroidx = self.grid.model.index(0, 0) zeroidx_rect = self.grid.visualRect(zeroidx) minidx = self.grid.model.index(min(rows), min(columns)) minidx_rect = self.grid.visualRect(minidx) maxidx = self.grid.model.index(max(rows), max(columns)) maxidx_rect = self.grid.visualRect(maxidx) grid_width = maxidx_rect.x() + maxidx_rect.width() \ - minidx_rect.x() grid_height = maxidx_rect.y() + maxidx_rect.height() \ - minidx_rect.y() grid_rect = QRectF(minidx_rect.x() - zeroidx_rect.x(), minidx_rect.y() - zeroidx_rect.y(), grid_width, grid_height) self.settings.print_zoom = min(page_rect.width() / grid_width, page_rect.height() / grid_height) with painter_save(painter): painter.scale(self.settings.print_zoom, self.settings.print_zoom) # Translate so that the grid starts at upper left paper edge painter.translate(zeroidx_rect.x() - minidx_rect.x(), zeroidx_rect.y() - minidx_rect.y()) # Draw grid cells self.workflows.paint(painter, option, grid_rect, rows, columns) self.settings.print_zoom = None if i != len(tables) - 1: printer.newPage() self.grid.table = old_table
[docs] def on_fullscreen(self): """Fullscreen toggle event handler""" if self.windowState() == Qt.WindowFullScreen: self.setWindowState(self._previous_window_state) else: self._previous_window_state = self.windowState() self.setWindowState(Qt.WindowFullScreen)
[docs] def on_approve(self): """Approve event handler""" if ApproveWarningDialog(self).choice: self.safe_mode = False
[docs] def on_clear_globals(self): """Clear globals event handler""" self.grid.model.code_array.result_cache.clear() # Clear globals self.grid.model.code_array.clear_globals() self.grid.model.code_array.reload_modules()
[docs] def on_preferences(self): """Preferences event handler (:class:`dialogs.PreferencesDialog`) """ data = PreferencesDialog(self).data if data is not None: max_file_history_changed = \ self.settings.max_file_history != data['max_file_history'] # Dialog has been approved --> Store data to settings for key in data: if key == "signature_key" and not data[key]: data[key] = genkey() self.settings.__setattr__(key, data[key]) # Immediately adjust file history in menu if max_file_history_changed: self.menuBar().file_menu.history_submenu.update()
[docs] def on_dependencies(self): """Dependancies installer (:class:`installer.InstallerDialog`) """ dial = DependenciesDialog(self) dial.exec_()
[docs] def on_undo(self): """Undo event handler""" self.undo_stack.undo()
[docs] def on_redo(self): """Undo event handler""" self.undo_stack.redo()
[docs] def on_toggle_refresh_timer(self, toggled: bool): """Toggles periodic timer for frozen cells :param toggled: Toggle state """ if toggled: self.refresh_timer.start(self.settings.refresh_timeout) else: self.refresh_timer.stop()
[docs] def on_refresh_timer(self): """Event handler for self.refresh_timer.timeout Called for periodic updates of frozen cells. Does nothing if either the entry_line or a cell editor is active. """ if not self.entry_line.hasFocus() \ and self.grid.state() != self.grid.EditingState: self.grid.refresh_frozen_cells()
[docs] def _toggle_widget(self, widget: QWidget, action_name: str, toggled: bool): """Toggles widget visibility and updates toggle actions :param widget: Widget to be toggled shown or hidden :param action_name: Name of action from Action class :param toggled: Toggle state """ if toggled: widget.show() else: widget.hide() self.main_window_actions[action_name].setChecked(widget.isVisible())
[docs] def on_toggle_main_toolbar(self, toggled: bool): """Main toolbar toggle event handler :param toggled: Toggle state """ self._toggle_widget(self.main_toolbar, "toggle_main_toolbar", toggled)
[docs] def on_toggle_macro_toolbar(self, toggled: bool): """Macro toolbar toggle event handler :param toggled: Toggle state """ self._toggle_widget(self.macro_toolbar, "toggle_macro_toolbar", toggled)
[docs] def on_toggle_format_toolbar(self, toggled: bool): """Format toolbar toggle event handler :param toggled: Toggle state """ self._toggle_widget(self.format_toolbar, "toggle_format_toolbar", toggled)
[docs] def on_toggle_find_toolbar(self, toggled: bool): """Find toolbar toggle event handler :param toggled: Toggle state """ self._toggle_widget(self.find_toolbar, "toggle_find_toolbar", toggled)
[docs] def on_toggle_entry_line_dock(self, toggled: bool): """Entryline toggle event handler :param toggled: Toggle state """ self._toggle_widget(self.entry_line_dock, "toggle_entry_line_dock", toggled)
[docs] def on_toggle_macro_dock(self, toggled: bool): """Macro panel toggle event handler :param toggled: Toggle state """ self._toggle_widget(self.macro_dock, "toggle_macro_dock", toggled)
[docs] def on_manual(self): """Show manual browser""" dialog = ManualDialog(self) dialog.show()
[docs] def on_tutorial(self): """Show tutorial browser""" dialog = TutorialDialog(self) dialog.show()
[docs] def on_about(self): """Show about message box""" def devs_string(devs: list) -> str: """Get string from devs list""" devs_str = "".join("<li>{}</li>".format(dev) for dev in devs) return "<ul>{}</ul>".format(devs_str) about_msg_template = \ """<b>{name}</b><><p> A non-traditional Python spreadsheet application<p> Version:&emsp;{version}<p> Created by:&emsp;{devs}<p> Documented by:&emsp;{doc_devs}<p> Copyright:&emsp;{copyright_owner}<p> License:&emsp;{license}<p> Web site:&emsp;<a href="{web_url}">{web_url}</a> """ devs = ("Martin Manns", "Jason Sexauer", "Vova Kolobok", "mgunyho", "Pete Morgan") devs_str = devs_string(devs) doc_devs = ("Martin Manns", "Bosko Markovic", "Pete Morgan") doc_devs_str = devs_string(doc_devs) copyright_owner = "Martin Manns" about_msg = about_msg_template.format( name=APP_NAME, version=VERSION, license=LICENSE, devs=devs_str, doc_devs=doc_devs_str, copyright_owner=copyright_owner, web_url=WEB_URL) QMessageBox.about(self, "About {}".format(APP_NAME), about_msg)
[docs] def on_focus_changed(self, old: QWidget, now: QWidget): """Handles grid clicks from entry line""" if old == self.grid and now == self.entry_line: self.grid.selection_mode = False
[docs] def on_gui_update(self, attributes: CellAttributes): """GUI update that shall be called on each cell change :param attributes: Attributes of current cell """ widgets = self.widgets menubar = self.menuBar() is_bold = attributes.fontweight == QFont.Bold self.main_window_actions.bold.setChecked(is_bold) is_italic = attributes.fontstyle == QFont.StyleItalic self.main_window_actions.italics.setChecked(is_italic) underline_action = self.main_window_actions.underline underline_action.setChecked(attributes.underline) strikethrough_action = self.main_window_actions.strikethrough strikethrough_action.setChecked(attributes.strikethrough) renderer = attributes.renderer widgets.renderer_button.set_current_action(renderer) widgets.renderer_button.set_menu_checked(renderer) freeze_action = self.main_window_actions.freeze_cell freeze_action.setChecked(attributes.frozen) lock_action = self.main_window_actions.lock_cell lock_action.setChecked(attributes.locked) self.entry_line.setReadOnly(attributes.locked) button_action = self.main_window_actions.button_cell button_action.setChecked(attributes.button_cell is not False) rotation = "rotate_{angle}".format(angle=int(attributes.angle)) widgets.rotate_button.set_current_action(rotation) widgets.rotate_button.set_menu_checked(rotation) widgets.justify_button.set_current_action(attributes.justification) widgets.justify_button.set_menu_checked(attributes.justification) widgets.align_button.set_current_action(attributes.vertical_align) widgets.align_button.set_menu_checked(attributes.vertical_align) border_action = self.main_window_actions.border_group.checkedAction() if border_action is not None: icon = border_action.icon() menubar.format_menu.border_submenu.setIcon(icon) self.format_toolbar.border_menu_button.setIcon(icon) border_width_action = \ self.main_window_actions.border_width_group.checkedAction() if border_width_action is not None: icon = border_width_action.icon() menubar.format_menu.line_width_submenu.setIcon(icon) self.format_toolbar.line_width_button.setIcon(icon) if attributes.textcolor is None: text_color = self.grid.palette().color(QPalette.Text) else: text_color = QColor(*attributes.textcolor) widgets.text_color_button.color = text_color if attributes.bordercolor_bottom is None: line_color = self.grid.palette().color(QPalette.Mid) else: line_color = QColor(*attributes.bordercolor_bottom) widgets.line_color_button.color = line_color if attributes.bgcolor is None: bgcolor = self.grid.palette().color(QPalette.Base) else: bgcolor = QColor(*attributes.bgcolor) widgets.background_color_button.color = bgcolor if attributes.textfont is None: widgets.font_combo.font = QFont().family() else: widgets.font_combo.font = attributes.textfont widgets.font_size_combo.size = attributes.pointsize merge_cells_action = self.main_window_actions.merge_cells merge_cells_action.setChecked(attributes.merge_area is not None)
[docs]def excepthook(exception_type, exception_value, exception_traceback): """Exception hook that prevents pyspread from crashing on exceptions""" traceback_msg = "".join(traceback.format_exception(exception_type, exception_value, exception_traceback)) print("Error: {}\n".format(traceback_msg))
[docs]def main(): """Pyspread main""" sys.excepthook = excepthook parser = PyspreadArgumentParser() args, unknown = parser.parse_known_args() app = QApplication(sys.argv) main_window = MainWindow(args.file, default_settings=args.default_settings) main_window.show() app.exec_() sys.exit()
if __name__ == '__main__': main()