# -*- 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's main grid
**Provides**
* :class:`Grid`: QTableView of the main grid
* :class:`GridHeaderView`: QHeaderView for the main grids headers
* :class:`GridTableModel`: QAbstractTableModel linking the view to code_array
backend
* :class:`GridCellDelegate`: QStyledItemDelegate handling custom painting and
editors
* :class:`TableChoice`: The TabBar below the main grid
"""
from ast import literal_eval
from contextlib import contextmanager
from io import BytesIO
from typing import Any, Iterable, List, Tuple, Union
import numpy
from PyQt6.QtWidgets \
import (QTableView, QStyledItemDelegate, QTabBar, QWidget, QMainWindow,
QStyleOptionViewItem, QApplication, QStyle, QAbstractItemDelegate,
QHeaderView, QFontDialog, QInputDialog, QLineEdit,
QAbstractItemView)
from PyQt6.QtGui \
import (QColor, QBrush, QFont, QPainter, QPalette, QImage, QKeyEvent,
QTextOption, QAbstractTextDocumentLayout, QTextDocument,
QWheelEvent, QContextMenuEvent, QTextCursor)
from PyQt6.QtCore \
import (Qt, QAbstractTableModel, QModelIndex, QVariant, QEvent, QSize,
QRect, QRectF, QItemSelectionModel, QObject, QAbstractItemModel,
QByteArray, pyqtSignal)
from PyQt6.QtSvg import QSvgRenderer
try:
import matplotlib
import matplotlib.figure
except ImportError:
matplotlib = None
try:
from pyspread import commands
from pyspread.dialogs import DiscardDataDialog
from pyspread.grid_renderer import (painter_save, CellRenderer,
QColorCache, BorderWidthBottomCache,
BorderWidthRightCache,
EdgeBordersCache,
BorderColorRightCache,
BorderColorBottomCache)
from pyspread.model.model import (CodeArray, CellAttribute,
DefaultCellAttributeDict)
from pyspread.lib.attrdict import AttrDict
from pyspread.interfaces.pys import (qt52qt6_fontweights,
qt62qt5_fontweights)
from pyspread.lib.selection import Selection
from pyspread.lib.string_helpers import quote, wrap_text
from pyspread.lib.qimage2ndarray import array2qimage
from pyspread.lib.typechecks import is_svg, check_shape_validity
from pyspread.menus import (GridContextMenu, TableChoiceContextMenu,
HorizontalHeaderContextMenu,
VerticalHeaderContextMenu)
from pyspread.widgets import CellButton
except ImportError:
import commands
from dialogs import DiscardDataDialog
from grid_renderer import (painter_save, CellRenderer, QColorCache,
BorderWidthBottomCache, BorderWidthRightCache,
EdgeBordersCache, BorderColorRightCache,
BorderColorBottomCache)
from model.model import CodeArray, CellAttribute, DefaultCellAttributeDict
from lib.attrdict import AttrDict
from interfaces.pys import qt52qt6_fontweights, qt62qt5_fontweights
from lib.selection import Selection
from lib.string_helpers import quote, wrap_text
from lib.qimage2ndarray import array2qimage
from lib.typechecks import is_svg, check_shape_validity
from menus import (GridContextMenu, TableChoiceContextMenu,
HorizontalHeaderContextMenu, VerticalHeaderContextMenu)
from widgets import CellButton
FONTSTYLES = (QFont.Style.StyleNormal,
QFont.Style.StyleItalic,
QFont.Style.StyleOblique)
[docs]
class Grid(QTableView):
"""The main grid of pyspread"""
def __init__(self, main_window: QMainWindow, model=None):
"""
:param main_window: Application main window
:param model: GridTableModel for grid
"""
super().__init__()
self.main_window = main_window
shape = main_window.settings.shape
if model is None:
self.model = GridTableModel(main_window, shape)
else:
self.model = model
self.setModel(self.model)
self.qcolor_cache = QColorCache(self)
self.borderwidth_bottom_cache = BorderWidthBottomCache(self)
self.borderwidth_right_cache = BorderWidthRightCache(self)
self.edge_borders_cache = EdgeBordersCache()
self.border_color_bottom_cache = BorderColorBottomCache(self)
self.border_color_right_cache = BorderColorRightCache(self)
self.table_choice = main_window.table_choice
self.widget_indices = [] # Store each index with an indexWidget here
# Signals
self.model.dataChanged.connect(self.on_data_changed)
self.selectionModel().currentChanged.connect(self.on_current_changed)
self.selectionModel().selectionChanged.connect(
self.on_selection_changed)
self.setHorizontalHeader(GridHeaderView(Qt.Orientation.Horizontal,
self))
self.setVerticalHeader(GridHeaderView(Qt.Orientation.Vertical, self))
self.verticalHeader().setDefaultSectionSize(
self.main_window.settings.default_row_height)
self.horizontalHeader().setDefaultSectionSize(
self.main_window.settings.default_column_width)
self.verticalHeader().setMinimumSectionSize(0)
self.horizontalHeader().setMinimumSectionSize(0)
# Palette adjustment for cases in which the Base color is not white
palette = self.palette()
palette.setColor(QPalette.ColorRole.Base,
QColor(*DefaultCellAttributeDict().bgcolor))
self.setPalette(palette)
self.setCornerButtonEnabled(False)
self._zoom = 1.0 # Initial zoom level for the grid
self.current_selection_mode_start = None
self.selection_mode_exiting = False # True only during exit
self.verticalHeader().sectionResized.connect(self.on_row_resized)
self.horizontalHeader().sectionResized.connect(self.on_column_resized)
self.setShowGrid(False)
self.delegate = GridCellDelegate(main_window, self,
self.model.code_array)
self.setItemDelegate(self.delegate)
# Select upper left cell because initial selection behaves strange
self.reset_selection()
# Locking states for operations by undo and redo operations
self.__undo_resizing_row = False
self.__undo_resizing_column = False
# Initially, select top left cell on table 0
self.current = 0, 0, 0
# Store initial viewport
self.table_scrolls = {0: (self.verticalScrollBar().value(),
self.horizontalScrollBar().value())}
[docs]
@contextmanager
def undo_resizing_row(self):
"""Sets self.__undo_resizing_row to True for context"""
self.__undo_resizing_row = True
yield
self.__undo_resizing_row = False
[docs]
@contextmanager
def undo_resizing_column(self):
"""Sets self.__undo_resizing_column to True for context"""
self.__undo_resizing_column = True
yield
self.__undo_resizing_column = False
@property
def row(self) -> int:
"""Current row"""
return self.currentIndex().row()
@row.setter
def row(self, value: int):
"""Sets current row to value
:param value: Row to be made current
"""
self.current = value, self.column
@property
def column(self) -> int:
"""Current column"""
return self.currentIndex().column()
@column.setter
def column(self, value: int):
"""Sets current column to value
:param value: Column to be made current
"""
self.current = self.row, value
@property
def table(self) -> int:
"""Current table"""
return self.table_choice.table
@table.setter
def table(self, value: int):
"""Sets current table
:param value: Table to be made current
"""
if 0 <= value < self.model.shape[2]:
self.table_choice.table = value
@property
def current(self) -> Tuple[int, int, int]:
"""Tuple of row, column, table of the current index"""
return self.row, self.column, self.table
@current.setter
def current(self, value: Union[Tuple[int, int, int], Tuple[int, int]]):
"""Sets the current index to row, column and if given table
:param value: Key of cell to be made current
"""
if len(value) not in (2, 3):
msg = "Current cell must be defined with a tuple " + \
"(row, column) or (rol, column, table)."
raise ValueError(msg)
row, column, *table_list = value
if not 0 <= row < self.model.shape[0]:
row = self.row
if not 0 <= column < self.model.shape[1]:
column = self.column
if table_list:
self.table = table_list[0]
index = self.model.index(row, column, QModelIndex())
self.setCurrentIndex(index)
@property
def row_heights(self) -> List[Tuple[int, float]]:
"""Returns list of tuples (row_index, row height) for current table"""
row_heights = self.model.code_array.row_heights
return [(row, row_heights[row, tab]) for row, tab in row_heights
if tab == self.table]
@property
def column_widths(self) -> List[Tuple[int, float]]:
"""Returns list of tuples (col_index, col_width) for current table"""
col_widths = self.model.code_array.col_widths
return [(col, col_widths[col, tab]) for col, tab in col_widths
if tab == self.table]
@property
def selection(self) -> Selection:
"""Pyspread selection based on self's QSelectionModel"""
if len(self.selected_idx) == 1:
# Return current cell selection to get accurate results
current = tuple(self.main_window.focused_grid.current[:2])
return Selection([], [], [], [], [current])
selection = self.main_window.focused_grid.selectionModel().selection()
block_top_left = []
block_bottom_right = []
rows = []
columns = []
cells = []
# Selection are made of selection ranges that we call span
for span in selection:
top, bottom = span.top(), span.bottom()
left, right = span.left(), span.right()
if top == bottom and left == right:
# The span is a single cell
cells.append((top, right))
elif left == 0 and right == self.model.shape[1] - 1:
# The span consists of selected rows
rows += list(range(top, bottom + 1))
elif top == 0 and bottom == self.model.shape[0] - 1:
# The span consists of selected columns
columns += list(range(left, right + 1))
else:
# Otherwise append a block
block_top_left.append((top, left))
block_bottom_right.append((bottom, right))
return Selection(block_top_left, block_bottom_right,
rows, columns, cells)
@property
def selected_idx(self) -> List[QModelIndex]:
"""Currently selected indices"""
return self.main_window.focused_grid.selectionModel().selectedIndexes()
@property
def zoom(self) -> float:
"""Returns zoom level"""
return self._zoom
@zoom.setter
def zoom(self, zoom: float):
"""Updates _zoom property and zoom visualization of the grid
Does nothing if not between minimum and maximum of settings.zoom_levels
:param zoom: Zoom level to be set
"""
zoom_levels = self.main_window.settings.zoom_levels
if min(zoom_levels) <= zoom <= max(zoom_levels):
self._zoom = zoom
self.update_zoom()
@property
def selection_mode(self) -> bool:
"""In selection mode, cells cannot be edited"""
return self.editTriggers() \
== QAbstractItemView.EditTrigger.NoEditTriggers
@selection_mode.setter
def selection_mode(self, on: bool):
"""Sets or unsets selection mode for this grid
In selection mode, cells cannot be edited.
This triggers the selection_mode icon in the statusbar.
:param on: If True, selection mode is set, if False unset
"""
grid = self.main_window.focused_grid
if on:
self.current_selection_mode_start = tuple(grid.current)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.main_window.selection_mode_widget.show()
else:
self.selection_mode_exiting = True
if self.current_selection_mode_start is not None:
grid.current = self.current_selection_mode_start
self.current_selection_mode_start = None
self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked
| QAbstractItemView.EditTrigger.EditKeyPressed
| QAbstractItemView.EditTrigger.AnyKeyPressed)
self.selection_mode_exiting = False
self.main_window.selection_mode_widget.hide()
self.main_window.entry_line.setFocus()
[docs]
def set_selection_mode(self, value=True):
"""Setter for selection mode for all grids
:param value: If True, selection mode is set, if False unset
"""
# All grids must simultaneously got into or out of selection mode
for grid in self.main_window.grids:
grid.selection_mode = value
# Adjust the menu
main_window_actions = self.main_window.main_window_actions
toggle_selection_mode = main_window_actions.toggle_selection_mode
toggle_selection_mode.setChecked(value)
[docs]
def toggle_selection_mode(self):
"""Toggle selection mode for all grids
This method is required for accessing selection mode from QActions.
"""
main_window_actions = self.main_window.main_window_actions
toggle_selection_mode = main_window_actions.toggle_selection_mode
value = toggle_selection_mode.toggled
for grid in self.main_window.grids:
grid.selection_mode = value
# Overrides
[docs]
def focusInEvent(self, event):
"""Overrides focusInEvent storing last focused grid in main_window"""
self.main_window._last_focused_grid = self
super().focusInEvent(event)
[docs]
def closeEditor(self, editor: QWidget,
hint: QAbstractItemDelegate.EndEditHint):
"""Overrides QTableView.closeEditor
Changes to overridden behavior:
* Data is submitted when a cell is changed without pressing <Enter>
e.g. by mouse click or arrow keys.
:param editor: Editor to be closed
:param hint: Hint to be overridden if == `QAbstractItemDelegate.NoHint`
"""
if hint == QAbstractItemDelegate.EndEditHint.NoHint:
hint = QAbstractItemDelegate.EndEditHint.SubmitModelCache
super().closeEditor(editor, hint)
[docs]
def keyPressEvent(self, event: QKeyEvent):
"""Overrides QTableView.keyPressEvent
Changes to overridden behavior:
* If Shift is pressed, the cell in the next column is selected.
* If Shift is not pressed, the cell in the next row is selected.
:param event: Key event
"""
if event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
if self.selection_mode:
# Return exits selection mode
self.selection_mode = False
self.main_window.entry_line.setFocus()
elif event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
self.current = self.row, self.column + 1
else:
self.current = self.row + 1, self.column
elif event.key() == Qt.Key.Key_Delete:
self.main_window.workflows.delete()
elif (event.key() == Qt.Key.Key_Escape
and self.editTriggers()
== QAbstractItemView.EditTrigger.NoEditTriggers):
# Leave cell selection mode
self.selection_mode = False
else:
super().keyPressEvent(event)
[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:
self.on_zoom_in()
else:
self.on_zoom_out()
else:
super().wheelEvent(event)
# Helpers
[docs]
def reset_selection(self):
"""Select upper left cell"""
self.setSelection(QRect(1, 1, 1, 1),
QItemSelectionModel.SelectionFlag.Select)
[docs]
def gui_update(self):
"""Emits gui update signal"""
attributes = self.model.code_array.cell_attributes[self.current]
self.main_window.gui_update.emit(attributes)
[docs]
def adjust_size(self):
"""Adjusts size to header maxima"""
horizontal_header = self.horizontalHeader()
vertical_header = self.verticalHeader()
width = horizontal_header.length() + vertical_header.width()
height = vertical_header.length() + horizontal_header.height()
self.resize(width, height)
[docs]
def _selected_idx_to_str(self, selected_idx: Iterable[QModelIndex]) -> str:
"""Converts selected_idx to string with cell indices
:param selected_idx: Indices of selected cells
"""
if len(selected_idx) <= 6:
return ", ".join(str(self.model.current(idx))
for idx in selected_idx)
return ", ".join(str(self.model.current(idx))
for idx in selected_idx[:6]) + "..."
[docs]
def update_zoom(self):
"""Updates the zoom level visualization to the current zoom factor"""
self.verticalHeader().update_zoom()
self.horizontalHeader().update_zoom()
[docs]
def has_selection(self) -> bool:
"""Returns True if more than one cell is selected, else False
This method handles spanned/merged cells. One single cell that is
selected is considered as no cell being selected.
"""
cell_attributes = self.model.code_array.cell_attributes
merge_area = cell_attributes[self.current].merge_area
if merge_area is None:
merge_sel = Selection([], [], [], [], [])
else:
top, left, bottom, right = merge_area
merge_sel = Selection([(top, left)], [(bottom, right)], [], [], [])
return not (self.selection.single_cell_selected()
or merge_sel.get_bbox() == self.selection.get_bbox())
# Event handlers
[docs]
def on_data_changed(self):
"""Event handler for data changes"""
self.qcolor_cache.clear()
self.borderwidth_bottom_cache.clear()
self.borderwidth_right_cache.clear()
self.edge_borders_cache.clear()
self.border_color_bottom_cache.clear()
self.border_color_right_cache.clear()
if not self.main_window.settings.changed_since_save:
self.main_window.settings.changed_since_save = True
main_window_title = "* " + self.main_window.windowTitle()
self.main_window.setWindowTitle(main_window_title)
[docs]
def on_current_changed(self, *_: Any):
"""Event handler for change of current cell"""
if self.selection_mode_exiting:
# Do not update entry_line to preserve selection
return
if self.selection_mode:
cursor = self.main_window.entry_line.textCursor()
text_anchor = cursor.anchor()
text_position = cursor.position()
if QApplication.queryKeyboardModifiers() \
== Qt.KeyboardModifier.MetaModifier:
text = self.selection.get_absolute_access_string(
self.model.shape, self.table)
else:
text = self.selection.get_relative_access_string(
self.model.shape, self.current_selection_mode_start)
self.main_window.entry_line.insertPlainText(text)
cursor.setPosition(min(text_anchor, text_position))
cursor.setPosition(min(text_anchor, text_position) + len(text),
QTextCursor.MoveMode.KeepAnchor)
self.main_window.entry_line.setTextCursor(cursor)
else:
code = self.model.code_array(self.current)
self.main_window.entry_line.setPlainText(code)
self.gui_update()
[docs]
def on_selection_changed(self):
"""Selection changed event handler"""
if not self.main_window.settings.show_statusbar_sum:
return
try:
selection = self.selection
code_array = self.model.code_array
single_cell_selected = selection.single_cell_selected()
except AttributeError:
return
if not selection or single_cell_selected:
self.main_window.statusBar().clearMessage()
return
selected_cell_list = list(selection.cell_generator(self.model.shape,
self.table))
res_gen = (code_array[key] for key in selected_cell_list
if code_array(key))
sum_list = [res for res in res_gen if res is not None]
msg_tpl = " " + " ".join(["Σ={}", "max={}", "min={}"])
msg = f"Selection: {len(selected_cell_list)} cells"
if sum_list:
try:
msg += msg_tpl.format(sum(sum_list), max(sum_list),
min(sum_list))
except Exception:
pass
self.main_window.statusBar().showMessage(msg)
[docs]
def on_row_resized(self, row: int, old_height: float, new_height: float):
"""Row resized event handler
:param row: Row that is resized
:param old_height: Row height before resizing
:param new_height: Row height after resizing
"""
if self.__undo_resizing_row: # Resize from undo or redo command
return
(top, _), (bottom, _) = self.selection.get_grid_bbox(self.model.shape)
if bottom - top > 1 and top <= row <= bottom:
rows = list(range(top, bottom + 1))
else:
rows = [row]
description = f"Resize rows {rows} to {new_height}"
command = commands.SetRowsHeight(self, rows, self.table,
old_height / self.zoom,
new_height / self.zoom, description)
self.main_window.undo_stack.push(command)
[docs]
def on_column_resized(self, column: int, old_width: float,
new_width: float):
"""Column resized event handler
:param row: Column that is resized
:param old_width: Column width before resizing
:param new_width: Column width after resizing
"""
if self.__undo_resizing_column: # Resize from undo or redo command
return
(_, left), (_, right) = self.selection.get_grid_bbox(self.model.shape)
if right - left > 1 and left <= column <= right:
columns = list(range(left, right + 1))
else:
columns = [column]
description = f"Resize columns {columns} to {new_width}"
command = commands.SetColumnsWidth(self, columns, self.table,
old_width / self.zoom,
new_width / self.zoom, description)
self.main_window.undo_stack.push(command)
[docs]
def on_zoom_in(self):
"""Zoom in event handler"""
grid = self.main_window.focused_grid
zoom_levels = self.main_window.settings.zoom_levels
larger_zoom_levels = [zl for zl in zoom_levels if zl > grid.zoom]
if larger_zoom_levels:
grid.zoom = min(larger_zoom_levels)
[docs]
def on_zoom_out(self):
"""Zoom out event handler"""
grid = self.main_window.focused_grid
zoom_levels = self.main_window.settings.zoom_levels
smaller_zoom_levels = [zl for zl in zoom_levels if zl < grid.zoom]
if smaller_zoom_levels:
grid.zoom = max(smaller_zoom_levels)
[docs]
def on_zoom_1(self):
"""Sets zoom level ot 1.0"""
grid = self.main_window.focused_grid
grid.zoom = 1.0
[docs]
def _refresh_frozen_cell(self, key: Tuple[int, int, int]):
"""Refreshes the frozen cell key
Does neither emit dataChanged nor clear _attr_cache or _table_cache.
:param key: Key of cell to be refreshed
"""
if self.model.code_array.cell_attributes[key].frozen:
code = self.model.code_array(key)
result = self.model.code_array._eval_cell(key, code)
self.model.code_array.frozen_cache[repr(key)] = result
[docs]
def refresh_frozen_cells(self):
"""Refreshes all frozen cells"""
frozen_cache = self.model.code_array.frozen_cache
cell_attributes = self.model.code_array.cell_attributes
for repr_key in frozen_cache:
key = literal_eval(repr_key)
self._refresh_frozen_cell(key)
self.model.dataChanged.emit(QModelIndex(), QModelIndex())
[docs]
def refresh_selected_frozen_cells(self):
"""Refreshes selected frozen cells"""
for idx in self.selected_idx:
self._refresh_frozen_cell((idx.row(), idx.column(), self.table))
self.model.code_array.cell_attributes._attr_cache.clear()
self.model.code_array.cell_attributes._table_cache.clear()
self.model.code_array.result_cache.clear()
self.model.dataChanged.emit(QModelIndex(), QModelIndex())
[docs]
def on_show_frozen_pressed(self, toggled: bool):
"""Show frozen cells event handler
:param toggled: Toggle state
"""
self.main_window.settings.show_frozen = toggled
[docs]
def on_font_dialog(self):
"""Font dialog event handler"""
# Determine currently active font as dialog preset
font = self.model.font(self.current)
font, ok = QFontDialog().getFont(font, self.main_window)
if ok:
attr_dict = AttrDict()
attr_dict.textfont = font.family()
attr_dict.pointsize = font.pointSizeF()
attr_dict.fontweight = qt62qt5_fontweights(font.weight())
attr_dict.fontstyle = FONTSTYLES.index(font.style())
attr_dict.underline = font.underline()
attr_dict.strikethrough = font.strikeOut()
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set font {font} for indices {idx_string}"
command = commands.SetCellFormat(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_font(self):
"""Font change event handler"""
font = self.main_window.widgets.font_combo.font
attr_dict = AttrDict([("textfont", font)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set font {font} for indices {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_font_size(self):
"""Font size change event handler"""
size = self.main_window.widgets.font_size_combo.size
attr_dict = AttrDict([("pointsize", size)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set font size {size} for cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_bold_pressed(self, toggled: bool):
"""Bold button pressed event handler
:param toggled: Toggle state
"""
fontweight = QFont.Weight.Bold if toggled else QFont.Weight.Normal
attr_dict = AttrDict([("fontweight", qt62qt5_fontweights(fontweight))])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set font weight {fontweight} for cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_italics_pressed(self, toggled: bool):
"""Italics button pressed event handler
:param toggled: Toggle state
"""
fontstyle = QFont.Style.StyleItalic \
if toggled else QFont.Style.StyleNormal
attr_dict = AttrDict([("fontstyle", FONTSTYLES.index(fontstyle))])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set font style {fontstyle} for cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_underline_pressed(self, toggled: bool):
"""Underline button pressed event handler
:param toggled: Toggle state
"""
attr_dict = AttrDict([("underline", toggled)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set font underline {toggled} for cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_strikethrough_pressed(self, toggled: bool):
"""Strikethrough button pressed event handler
:param toggled: Toggle state
"""
attr_dict = AttrDict([("strikethrough", toggled)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = \
f"Set font strikethrough {toggled} for cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_text_renderer_pressed(self):
"""Text renderer button pressed event handler"""
attr_dict = AttrDict([("renderer", "text")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set text renderer for cells {idx_string}"
entry_line = self.main_window.entry_line
document = entry_line.document()
# Disable highlighter to speed things up
highlighter_limit = self.main_window.settings.highlighter_limit
if len(document.toRawText()) > highlighter_limit:
document = None
command = commands.SetCellRenderer(attr, self.model, entry_line,
document, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_image_renderer_pressed(self):
"""Image renderer button pressed event handler"""
attr_dict = AttrDict([("renderer", "image")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set image renderer for cells {idx_string}"
entry_line = self.main_window.entry_line
command = commands.SetCellRenderer(attr, self.model, entry_line, None,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_markup_renderer_pressed(self):
"""Markup renderer button pressed event handler"""
attr_dict = AttrDict([("renderer", "markup")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set markup renderer for cells {idx_string}"
entry_line = self.main_window.entry_line
document = entry_line.document()
# Disable highlighter to speed things up
highlighter_limit = self.main_window.settings.highlighter_limit
if len(document.toRawText()) > highlighter_limit:
document = None
command = commands.SetCellRenderer(attr, self.model, entry_line,
document, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_matplotlib_renderer_pressed(self):
"""Matplotlib renderer button pressed event handler"""
attr_dict = AttrDict([("renderer", "matplotlib")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set matplotlib renderer for cells {idx_string}"
entry_line = self.main_window.entry_line
document = entry_line.document()
# Disable highlighter to speed things up
highlighter_limit = self.main_window.settings.highlighter_limit
if len(document.toRawText()) > highlighter_limit:
document = None
command = commands.SetCellRenderer(attr, self.model, entry_line,
document, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_lock_pressed(self, toggled: bool):
"""Lock button pressed event handler
:param toggled: Toggle state
"""
attr_dict = AttrDict([("locked", toggled)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set locked state to {toggled} for cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_rotate_0(self):
"""Set cell rotation to 0° left button pressed event handler"""
attr_dict = AttrDict([("angle", 0.0)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set cell rotation to 0° for cells {idx_string}"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_rotate_90(self):
"""Set cell rotation to 90° left button pressed event handler"""
attr_dict = AttrDict([("angle", 90.0)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set cell rotation to 90° for cells {idx_string}"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_rotate_180(self):
"""Set cell rotation to 180° left button pressed event handler"""
attr_dict = AttrDict([("angle", 180.0)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set cell rotation to 180° for cells {idx_string}"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_rotate_270(self):
"""Set cell rotation to 270° left button pressed event handler"""
attr_dict = AttrDict([("angle", 270.0)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set cell rotation to 270° for cells {idx_string}"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_justify_left(self):
"""Justify left button pressed event handler"""
attr_dict = AttrDict([("justification", "justify_left")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Justify cells {idx_string} left"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_justify_fill(self):
"""Justify fill button pressed event handler"""
attr_dict = AttrDict([("justification", "justify_fill")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Justify cells {idx_string} filled"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_justify_center(self):
"""Justify center button pressed event handler"""
attr_dict = AttrDict([("justification", "justify_center")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Justify cells {idx_string} centered"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_justify_right(self):
"""Justify right button pressed event handler"""
attr_dict = AttrDict([("justification", "justify_right")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Justify cells {idx_string} right"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_align_top(self):
"""Align top button pressed event handler"""
attr_dict = AttrDict([("vertical_align", "align_top")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Align cells {idx_string} to top"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_align_middle(self):
"""Align centere button pressed event handler"""
attr_dict = AttrDict([("vertical_align", "align_center")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Align cells {idx_string} to center"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_align_bottom(self):
"""Align bottom button pressed event handler"""
attr_dict = AttrDict([("vertical_align", "align_bottom")])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Align cells {idx_string} to bottom"
command = commands.SetCellTextAlignment(attr, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_border_choice(self):
"""Border choice style event handler"""
self.main_window.settings.border_choice = self.sender().text()
self.gui_update()
[docs]
def on_text_color(self):
"""Text color change event handler"""
text_color = self.main_window.widgets.text_color_button.color
text_color_rgb = text_color.getRgb()
attr_dict = AttrDict([("textcolor", text_color_rgb)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = \
f"Set text color to {text_color_rgb} for cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_line_color(self):
"""Line color change event handler"""
border_choice = self.main_window.settings.border_choice
bottom_selection = \
self.selection.get_bottom_borders_selection(border_choice,
self.model.shape)
right_selection = \
self.selection.get_right_borders_selection(border_choice,
self.model.shape)
line_color = self.main_window.widgets.line_color_button.color
line_color_rgb = line_color.getRgb()
attr_dict_bottom = AttrDict([("bordercolor_bottom", line_color_rgb)])
attr_bottom = CellAttribute(bottom_selection, self.table,
attr_dict_bottom)
attr_dict_right = AttrDict([("bordercolor_right", line_color_rgb)])
attr_right = CellAttribute(right_selection, self.table,
attr_dict_right)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set line color {line_color_rgb} for cells {idx_string}"
command = commands.SetCellFormat(attr_bottom, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
command = commands.SetCellFormat(attr_right, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_background_color(self):
"""Background color change event handler"""
bg_color = self.main_window.widgets.background_color_button.color
bg_color_rgb = bg_color.getRgb()
self.gui_update()
attr_dict = AttrDict([("bgcolor", bg_color_rgb)])
attr = CellAttribute(self.selection, self.table, attr_dict)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set cell background color to {bg_color_rgb} for " +\
f"cells {idx_string}"
command = commands.SetCellFormat(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def on_borderwidth(self):
"""Border width change event handler"""
width = int(self.sender().text().split()[-1])
border_choice = self.main_window.settings.border_choice
bottom_selection = \
self.selection.get_bottom_borders_selection(border_choice,
self.model.shape)
right_selection = \
self.selection.get_right_borders_selection(border_choice,
self.model.shape)
attr_dict_bottom = AttrDict([("borderwidth_bottom", width)])
attr_bottom = CellAttribute(bottom_selection, self.table,
attr_dict_bottom)
attr_dict_right = AttrDict([("borderwidth_right", width)])
attr_right = CellAttribute(right_selection, self.table,
attr_dict_right)
idx_string = self._selected_idx_to_str(self.selected_idx)
description = f"Set border width to {width} for cells {idx_string}"
command = commands.SetCellFormat(attr_bottom, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
command = commands.SetCellFormat(attr_right, self.model,
self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
[docs]
def update_cell_spans(self):
"""Update cell spans from model data"""
self.clearSpans()
spans = {} # Dict of (top, left): (bottom, right)
for _, table, attrs in self.model.code_array.cell_attributes:
if table == self.table:
try:
if "merge_area" in attrs and attrs.merge_area is not None:
top, left, bottom, right = attrs["merge_area"]
spans[(top, left)] = bottom, right
except (KeyError, TypeError):
pass
for top, left in spans:
try:
bottom, right = spans[(top, left)]
self.setSpan(top, left, bottom-top+1, right-left+1)
except TypeError:
pass
[docs]
def on_freeze_pressed(self, toggled: bool):
"""Freeze cell event handler
:param toggled: Toggle state
"""
grid = self.main_window.focused_grid
current_attr = self.model.code_array.cell_attributes[grid.current]
if current_attr.frozen == toggled:
return # Something is wrong with the GUI update
cells = list(self.selection.cell_generator(shape=self.model.shape,
table=self.table))
if toggled:
# We have an non-frozen cell that has to be frozen
description = f"Freeze cells {cells}"
command = commands.FreezeCell(self.model, cells, description)
else:
# We have an frozen cell that has to be unfrozen
description = f"Thaw cells {cells}"
command = commands.ThawCell(self.model, cells, description)
self.main_window.undo_stack.push(command)
[docs]
def on_merge_pressed(self):
"""Merge cells button pressed event handler"""
grid = self.main_window.focused_grid
# This is not done in the model because setSpan does not work there
shape = list(self.model.shape)
shape[0] -= 1
shape[1] -= 1
bbox = self.selection.get_grid_bbox(shape)
(top, left), (bottom, right) = bbox
# Check if current cell is already merged
if self.columnSpan(top, left) > 1 or self.rowSpan(top, left) > 1:
selection = Selection([], [], [], [], [(top, left)])
attr_dict = AttrDict([("merge_area", None)])
attr = CellAttribute(selection, self.table, attr_dict)
description = f"Unmerge cells with top-left cell {(top, left)}"
elif bottom > top or right > left:
# Merge and store the current selection
merging_selection = Selection([], [], [], [], [(top, left)])
attr_dict = AttrDict([("merge_area", (top, left, bottom, right))])
attr = CellAttribute(merging_selection, self.table, attr_dict)
description = "Merge cells with top-left cell {(top, left)}"
else:
# Cells are not merged because the span is one
return
command = commands.SetCellMerge(attr, self.model, self.currentIndex(),
self.selected_idx, description)
self.main_window.undo_stack.push(command)
grid.current = top, left
[docs]
def on_quote(self):
"""Quote cells event handler"""
description = f"Quote code for cell selection {id(self.selection)}"
for idx in self.selected_idx:
row = idx.row()
column = idx.column()
code = self.model.code_array((row, column, self.table))
quoted_code = quote(code)
index = self.model.index(row, column, QModelIndex())
command = commands.SetCellCode(quoted_code, self.model, index,
description)
self.main_window.undo_stack.push(command)
[docs]
def is_row_data_discarded(self, count: int) -> bool:
"""True if row data is to be discarded on row insertion
:param count: Rows to be inserted
"""
no_rows = self.model.shape[0]
rows = list(range(no_rows-count, no_rows+1))
selection = Selection([], [], rows, [], [])
sel_cell_gen = selection.cell_generator(self.model.shape, self.table)
return any(self.model.code_array(key) is not None
for key in sel_cell_gen)
[docs]
def is_column_data_discarded(self, count: int) -> bool:
"""True if column data is to be discarded on column insertion
:param count: Columns to be inserted
"""
no_columns = self.model.shape[1]
columns = list(range(no_columns-count, no_columns+1))
selection = Selection([], [], [], columns, [])
sel_cell_gen = selection.cell_generator(self.model.shape, self.table)
return any(self.model.code_array(key) is not None
for key in sel_cell_gen)
[docs]
def is_table_data_discarded(self, count: int) -> bool:
"""True if table data is to be discarded on table insertion
:param count: Tables to be inserted
"""
no_tables = self.model.shape[2]
tables = list(range(no_tables-count, no_tables+1))
return any(key[2] in tables and self.model.code_array(key) is not None
for key in self.model.code_array)
[docs]
def on_insert_rows(self):
"""Insert rows event handler"""
try:
(top, _), (bottom, _) = \
self.selection.get_grid_bbox(self.model.shape)
except TypeError:
top = bottom = self.row
count = bottom - top + 1
if self.is_row_data_discarded(count):
text = ("Inserting rows will discard data.\n \n"
"You may want to resize the grid before insertion.\n"
"Note that row insertion can be undone.")
if DiscardDataDialog(self.main_window, text).choice is not True:
return
index = self.currentIndex()
description = f"Insert {count} rows above row {top}"
command = commands.InsertRows(self, self.model, index, top, count,
description)
self.main_window.undo_stack.push(command)
[docs]
def on_delete_rows(self):
"""Delete rows event handler"""
try:
(top, _), (bottom, _) = \
self.selection.get_grid_bbox(self.model.shape)
except TypeError:
top = bottom = self.row
count = bottom - top + 1
index = self.currentIndex()
description = f"Delete {count} rows starting from row {top}"
command = commands.DeleteRows(self, self.model, index, top, count,
description)
self.main_window.undo_stack.push(command)
[docs]
def on_insert_columns(self):
"""Insert columns event handler"""
try:
(_, left), (_, right) = \
self.selection.get_grid_bbox(self.model.shape)
except TypeError:
left = right = self.column
count = right - left + 1
if self.is_column_data_discarded(count):
text = ("Inserting columns will discard data.\n \n"
"You may want to resize the grid before insertion.\n"
"Note that column insertion can be undone.")
if DiscardDataDialog(self.main_window, text).choice is not True:
return
index = self.currentIndex()
description = f"Insert {count} columns left of column {left}"
command = commands.InsertColumns(self, self.model, index, left, count,
description)
self.main_window.undo_stack.push(command)
[docs]
def on_delete_columns(self):
"""Delete columns event handler"""
try:
(_, left), (_, right) = \
self.selection.get_grid_bbox(self.model.shape)
except TypeError:
left = right = self.column
count = right - left + 1
index = self.currentIndex()
description = \
f"Delete {count} columns starting from column {self.column}"
command = commands.DeleteColumns(self, self.model, index, left, count,
description)
self.main_window.undo_stack.push(command)
[docs]
def on_insert_table(self):
"""Insert table event handler"""
if self.is_table_data_discarded(1):
text = ("Inserting tables will discard data.\n \n"
"You may want to resize the grid before insertion.\n"
"Note that table insertion can be undone.")
if DiscardDataDialog(self.main_window, text).choice is not True:
return
description = f"Insert table in front of table {self.table}"
command = commands.InsertTable(self, self.model, self.table,
description)
self.main_window.undo_stack.push(command)
[docs]
def on_delete_table(self):
"""Delete table event handler"""
description = f"Delete table {self.table}"
command = commands.DeleteTable(self, self.model, self.table,
description)
self.main_window.undo_stack.push(command)
[docs]
class GridTableModel(QAbstractTableModel):
"""QAbstractTableModel for Grid"""
cell_to_update = pyqtSignal(tuple)
def __init__(self, main_window: QMainWindow,
shape: Tuple[int, int, int]):
"""
:param main_window: Application main window
:param shape: Grid shape `(rows, columns, tables)`
"""
super().__init__()
self.main_window = main_window
self.code_array = CodeArray(shape, main_window.settings)
[docs]
@contextmanager
def model_reset(self):
"""Context manager for handle changing/resetting model data"""
self.beginResetModel()
yield
self.endResetModel()
[docs]
@contextmanager
def inserting_rows(self, index: QModelIndex, first: int, last: int):
"""Context manager for inserting rows
see `QAbstractItemModel.beginInsertRows`
:param index: Parent into which the new rows are inserted
:param first: Row number that first row will have after insertion
:param last: Row number that last row will have after insertion
"""
self.beginInsertRows(index, first, last)
yield
self.endInsertRows()
[docs]
@contextmanager
def inserting_columns(self, index: QModelIndex, first: int, last: int):
"""Context manager for inserting columns
see `QAbstractItemModel.beginInsertColumns`
:param index: Parent into which the new columns are inserted
:param first: Column number that first column will have after insertion
:param last: Column number that last column will have after insertion
"""
self.beginInsertColumns(index, first, last)
yield
self.endInsertColumns()
[docs]
@contextmanager
def removing_rows(self, index: QModelIndex, first: int, last: int):
"""Context manager for removing rows
see `QAbstractItemModel.beginRemoveRows`
:param index: Parent from which rows are removed
:param first: Row number of the first row to be removed
:param last: Row number of the last row to be removed
"""
self.beginRemoveRows(index, first, last)
yield
self.endRemoveRows()
[docs]
@contextmanager
def removing_columns(self, index: QModelIndex, first: int, last: int):
"""Context manager for removing columns
see `QAbstractItemModel.beginRemoveColumns`
:param index: Parent from which columns are removed
:param first: Column number of the first column to be removed
:param last: Column number of the last column to be removed
"""
self.beginRemoveColumns(index, first, last)
yield
self.endRemoveColumns()
@property
def grid(self) -> Grid:
"""The main grid"""
return self.main_window.grid
@property
def shape(self) -> Tuple[int, int, int]:
"""Returns 3-tuple of rows, columns and tables"""
return self.code_array.shape
@shape.setter
def shape(self, value: Tuple[int, int, int]):
"""Sets the shape in the code array and adjusts the table_choice
:param value: Grid shape `(rows, columns, tables)`
"""
check_shape_validity(value, self.main_window.settings.maxshape)
with self.model_reset():
self.code_array.shape = value
self.grid.table_choice.no_tables = value[2]
[docs]
def current(self, index: QModelIndex) -> Tuple[int, int, int]:
"""Tuple of row, column, table of given index
:param index: Index of the cell to be made the current cell
"""
return index.row(), index.column(), self.main_window.grid.table
[docs]
def code(self, index: QModelIndex) -> str:
"""Code in cell index
:param index: Index of the cell for which the code is returned
"""
return self.code_array(self.current(index))
[docs]
def rowCount(self, _: QModelIndex = QModelIndex()) -> int:
"""Overloaded `QAbstractItemModel.rowCount` for code_array backend"""
return self.shape[0]
[docs]
def columnCount(self, _: QModelIndex = QModelIndex()) -> int:
"""Overloaded `QAbstractItemModel.columnCount` for code_array backend
"""
return self.shape[1]
[docs]
def insertRows(self, row: int, count: int) -> bool:
"""Overloaded `QAbstractItemModel.insertRows` for code_array backend
:param row: Row at which rows are inserted
:param count: Number of rows to be inserted
"""
self.code_array.insert(row, count, axis=0, tab=self.grid.table)
return True
[docs]
def removeRows(self, row: int, count: int) -> bool:
"""Overloaded `QAbstractItemModel.removeRows` for code_array backend
:param row: Row at which rows are removed
:param count: Number of rows to be removed
"""
try:
self.code_array.delete(row, count, axis=0, tab=self.grid.table)
except ValueError:
return False
return True
[docs]
def insertColumns(self, column: int, count: int) -> bool:
"""Overloaded `QAbstractItemModel.insertColumns` for code_array backend
:param column: Column at which columns are inserted
:param count: Number of columns to be inserted
"""
self.code_array.insert(column, count, axis=1, tab=self.grid.table)
return True
[docs]
def removeColumns(self, column: int, count: int) -> bool:
"""Overloaded `QAbstractItemModel.removeColumns` for code_array backend
:param column: Column at which columns are removed
:param count: Number of columns to be removed
"""
try:
self.code_array.delete(column, count, axis=1, tab=self.grid.table)
except ValueError:
return False
return True
[docs]
def insertTable(self, table: int, count: int = 1):
"""Inserts tables
:param table: Table at which tables are inserted
:param count: Number of tables to be inserted
"""
self.code_array.insert(table, count, axis=2)
[docs]
def removeTable(self, table: int, count: int = 1):
"""Removes tables
:param table: Table at which tables are removed
:param count: Number of tables to be removed
"""
self.code_array.delete(table, count, axis=2)
[docs]
def font(self, key: Tuple[int, int, int]) -> QFont:
"""Returns font for given key
:param key: Key of cell, for which font is returned
"""
attr = self.code_array.cell_attributes[key]
font = QFont()
if attr.textfont is not None:
font.setFamily(attr.textfont)
if attr.pointsize is not None:
font.setPointSizeF(attr.pointsize)
if attr.fontweight is not None:
font.setWeight(qt52qt6_fontweights(attr.fontweight))
if attr.fontstyle is not None:
fontstyle = attr.fontstyle
if isinstance(fontstyle, int):
fontstyle = FONTSTYLES[fontstyle]
font.setStyle(fontstyle)
if attr.underline is not None:
font.setUnderline(attr.underline)
if attr.strikethrough is not None:
font.setStrikeOut(attr.strikethrough)
return font
[docs]
def data(self, index: QModelIndex,
role: Qt.ItemDataRole = Qt.ItemDataRole.DisplayRole) -> Any:
"""Overloaded data for code_array backend
:param index: Index of the cell, for which data is returned
:param role: Role of data to be returned
"""
def safe_str(obj) -> str:
"""Returns str(obj), on RecursionError returns error message"""
try:
return str(obj)
except Exception as err:
return str(err)
key = self.current(index)
if role == Qt.ItemDataRole.DisplayRole:
value = self.code_array[key]
renderer = self.code_array.cell_attributes[key].renderer
if renderer == "image" or value is None:
return ""
return safe_str(value)
if role == Qt.ItemDataRole.ToolTipRole:
value = self.code_array[key]
if value is None:
return ""
return wrap_text(safe_str(value))
if role == Qt.ItemDataRole.DecorationRole:
renderer = self.code_array.cell_attributes[key].renderer
if renderer == "image":
value = self.code_array[key]
if isinstance(value, QImage):
return value
try:
arr = numpy.array(value)
return array2qimage(arr)
except Exception:
return value
if role == Qt.ItemDataRole.BackgroundRole:
if self.main_window.settings.show_frozen \
and self.code_array.cell_attributes[key].frozen:
pattern_rgb = self.grid.palette().highlight().color()
bg_color = QBrush(pattern_rgb, Qt.BrushStyle.BDiagPattern)
else:
bg_color_rgb = self.code_array.cell_attributes[key].bgcolor
if bg_color_rgb is None:
bg_color = QColor(255, 255, 255)
else:
bg_color = QColor(*bg_color_rgb)
return bg_color
if role == Qt.ItemDataRole.ForegroundRole:
text_color_rgb = self.code_array.cell_attributes[key].textcolor
if text_color_rgb is None:
text_color = self.grid.palette().color(QPalette.ColorRole.Text)
else:
text_color = QColor(*text_color_rgb)
return text_color
if role == Qt.ItemDataRole.FontRole:
return self.font(key)
if role == Qt.ItemDataRole.TextAlignmentRole:
pys2qt = {
"justify_left": Qt.AlignmentFlag.AlignLeft,
"justify_center": Qt.AlignmentFlag.AlignHCenter,
"justify_right": Qt.AlignmentFlag.AlignRight,
"justify_fill": Qt.AlignmentFlag.AlignJustify,
"align_top": Qt.AlignmentFlag.AlignTop,
"align_center": Qt.AlignmentFlag.AlignVCenter,
"align_bottom": Qt.AlignmentFlag.AlignBottom,
}
attr = self.code_array.cell_attributes[key]
alignment = pys2qt[attr.vertical_align]
justification = pys2qt[attr.justification]
alignment |= justification
return alignment
return QVariant()
[docs]
def setData(self, index: QModelIndex, value: Any, role: Qt.ItemDataRole,
raw: bool = False, table: int = None) -> bool:
"""Overloaded setData for code_array backend
:param index: Index of the cell, for which data is set
:param value: Value of data to be set
:param role: Role of data to be set
:param raw: Sets raw data without string formatting in `EditRole`
:param table: Table for which data shall is set
"""
if role == Qt.ItemDataRole.EditRole:
if table is None:
key = self.current(index)
else:
key = index.row(), index.column(), table
if raw:
if value is None:
try:
self.code_array.pop(key)
except KeyError:
pass
else:
self.code_array[key] = value
else:
self.code_array[key] = f"{value}"
if not self.main_window.prevent_updates:
self.dataChanged.emit(index, index)
return True
if role in (Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.TextAlignmentRole):
if not isinstance(value[2], AttrDict):
raise Warning(f"{value[2]} has type {type(value[2])} that "
"is not instance of AttrDict")
self.code_array.cell_attributes.append(value)
# We have a selection and no single cell
with self.main_window.workflows.busy_cursor():
with self.main_window.entry_line.disable_updates():
with self.main_window.workflows.prevent_updates():
for idx in index:
self.dataChanged.emit(idx, idx)
return True
[docs]
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
"""Overloaded, makes items editable
:param index: Index of cell for which flags are returned
"""
return QAbstractTableModel.flags(self,
index) | Qt.ItemFlag.ItemIsEditable
[docs]
def reset(self):
"""Deletes all grid data including undo data"""
with self.model_reset():
# Clear cells
self.code_array.dict_grid.clear()
# Clear attributes
del self.code_array.dict_grid.cell_attributes[:]
# Clear row heights and column widths
self.code_array.row_heights.clear()
self.code_array.col_widths.clear()
# Clear macros
self.code_array.macros = ""
# Clear caches
# self.main_window.undo_stack.clear()
self.code_array.result_cache.clear()
# Clear globals
self.code_array.clear_globals()
self.code_array.reload_modules()
[docs]
class GridCellDelegate(QStyledItemDelegate):
"""QStyledItemDelegate for main grid QTableView"""
def __init__(self, main_window: QMainWindow, grid: Grid,
code_array: CodeArray):
"""
:param main_window: Application main window
:param grid: Grid, i.e. QTableView instance
:param code_array: Main backend model instance
"""
super().__init__()
self.main_window = main_window
self.grid = grid
self.code_array = code_array
self.cell_attributes = self.code_array.cell_attributes
[docs]
def _get_render_text_document(self, rect: QRectF,
option: QStyleOptionViewItem,
index: QModelIndex) -> QTextDocument:
"""Returns styled QTextDocument that is ready for setting content
:param rect: Cell rect of the cell to be painted
:param option: Style option for rendering
:param index: Index of cell for which markup is rendered
"""
doc = QTextDocument()
font = self.grid.model.data(index, role=Qt.ItemDataRole.FontRole)
doc.setDefaultFont(font)
alignment = self.grid.model.data(
index, role=Qt.ItemDataRole.TextAlignmentRole)
doc.setDefaultTextOption(QTextOption(alignment))
bg_color = self.grid.model.data(index,
role=Qt.ItemDataRole.BackgroundRole)
css = f"background-color: {bg_color};"
doc.setDefaultStyleSheet(css)
doc.setTextWidth(rect.width())
doc.setUseDesignMetrics(True)
text_option = doc.defaultTextOption()
text_option.setWrapMode(
QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
doc.setDefaultTextOption(text_option)
return doc
[docs]
def _render_text_document(self, doc: QTextDocument,
painter: QPainter, rect: QRectF,
option: QStyleOptionViewItem,
index: QModelIndex):
"""QTextDocument renderer
:param doc: Text document to be painted
:param painter: Painter with which markup is rendered
:param rect: Cell rect of the cell to be painted
:param option: Style option for rendering
:param index: Index of cell for which markup is rendered
"""
style = option.widget.style()
option.text = ""
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, option,
painter, option.widget)
ctx = QAbstractTextDocumentLayout.PaintContext()
text_color = self.grid.model.data(index,
role=Qt.ItemDataRole.ForegroundRole)
ctx.palette.setColor(QPalette.ColorRole.Text, text_color)
key = index.row(), index.column(), self.grid.table
vertical_align = self.cell_attributes[key].vertical_align
y_offset = 0
if vertical_align == 'align_center':
y_offset += rect.height() / 2 - doc.size().height() / 2
elif vertical_align == 'align_bottom':
y_offset += rect.height() - doc.size().height()
with painter_save(painter):
painter.translate(rect.x(), rect.y() + y_offset)
doc.documentLayout().draw(painter, ctx)
[docs]
def _render_text(self, painter: QPainter, rect: QRectF,
option: QStyleOptionViewItem, index: QModelIndex):
"""Text renderer
:param painter: Painter with which markup is rendered
:param rect: Cell rect of the cell to be painted
:param option: Style option for rendering
:param index: Index of cell for which markup is rendered
"""
self.initStyleOption(option, index)
doc = self._get_render_text_document(rect, option, index)
doc.setPlainText(option.text)
self._render_text_document(doc, painter, rect, option, index)
[docs]
def _render_markup(self, painter: QPainter, rect: QRectF,
option: QStyleOptionViewItem, index: QModelIndex):
"""HTML markup renderer
:param painter: Painter with which markup is rendered
:param rect: Cell rect of the cell to be painted
:param option: Style option for rendering
:param index: Index of cell for which markup is rendered
"""
self.initStyleOption(option, index)
doc = self._get_render_text_document(rect, option, index)
doc.setHtml(option.text)
self._render_text_document(doc, painter, rect, option, index)
[docs]
def _get_aligned_image_rect(
self, rect: QRectF, index: QModelIndex,
image_width: Union[int, float],
image_height: Union[int, float]) -> QRectF:
"""Returns image rect dependent on alignment and justification
:param rect: Rect to be aligned
:param image_width: Width of image [px]
:param image_height: Height of image [px]
"""
def scale_size(inner_width: Union[int, float],
inner_height: Union[int, float],
outer_width: Union[int, float],
outer_height: Union[int, float]) -> Tuple[float, float]:
"""Scales up inner_rect to fit in outer_rect
Returns width, height tuple that maintains aspect ratio.
:param inner_width: Width of inner rect (scaled to outer rect)
:param inner_height: Height of inner rect (scaled to outer rect)
:param outer_width: Width of outer rect
:param outer_height: Height of outer rect
"""
if inner_width and inner_height and outer_width and outer_height:
inner_aspect = inner_width / inner_height
outer_aspect = outer_width / outer_height
if outer_aspect < inner_aspect:
inner_width *= outer_width / inner_width
inner_height = inner_width / inner_aspect
else:
inner_height *= outer_height / inner_height
inner_width = inner_height * inner_aspect
return inner_width, inner_height
key = index.row(), index.column(), self.grid.table
justification = self.cell_attributes[key].justification
vertical_align = self.cell_attributes[key].vertical_align
if justification == "justify_fill":
return rect
try:
image_width, image_height = scale_size(image_width, image_height,
rect.width(), rect.height())
except ZeroDivisionError:
pass
image_x, image_y = rect.x(), rect.y()
if justification == "justify_center":
image_x = rect.x() + rect.width() / 2 - image_width / 2
elif justification == "justify_right":
image_x = rect.x() + rect.width() - image_width
if vertical_align == "align_center":
image_y = rect.y() + rect.height() / 2 - image_height / 2
elif vertical_align == "align_bottom":
image_y = rect.y() + rect.height() - image_height
return QRectF(image_x, image_y, image_width, image_height)
[docs]
def _render_qimage(self, painter: QPainter, rect: QRectF,
index: QModelIndex, qimage: QImage = None):
"""QImage renderer
:param painter: Painter with which qimage is rendered
:param rect: Cell rect of the cell to be painted
:param index: Index of cell for which qimage is rendered
:param qimage: Image to be rendered, decoration drawn if not provided
"""
if qimage is None:
qimage = index.data(Qt.ItemDataRole.DecorationRole)
if not isinstance(qimage, QImage):
raise TypeError(f"{qimage} not of type QImage")
img_width, img_height = qimage.width(), qimage.height()
img_rect = self._get_aligned_image_rect(rect, index,
img_width, img_height)
if img_rect is None:
return
key = index.row(), index.column(), self.grid.table
justification = self.cell_attributes[key].justification
if justification == "justify_fill":
qimage = qimage.scaled(int(img_width), int(img_height),
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation)
else:
qimage = qimage.scaled(int(img_width), int(img_height),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
with painter_save(painter):
try:
scale_x = img_rect.width() / img_width
except ZeroDivisionError:
scale_x = 1
try:
scale_y = img_rect.height() / img_height
except ZeroDivisionError:
scale_y = 1
painter.translate(img_rect.x(), img_rect.y())
painter.scale(scale_x, scale_y)
painter.drawImage(0, 0, qimage)
[docs]
def _render_svg(self, painter: QPainter, rect: QRectF, index: QModelIndex,
svg_str: str = None):
"""SVG renderer
:param painter: Painter with which qimage is rendered
:param rect: Cell rect of the cell to be painted
:param index: Index of cell for which qimage is rendered
:param svg_str: SVG string
"""
if svg_str is None:
svg_str = index.data(Qt.ItemDataRole.DecorationRole)
if svg_str is None:
return
try:
svg_bytes = bytes(svg_str)
except TypeError:
try:
svg_bytes = bytes(svg_str, encoding='utf-8')
except TypeError:
return
if not is_svg(svg_bytes):
return
key = index.row(), index.column(), self.grid.table
justification = self.cell_attributes[key].justification
svg = QSvgRenderer(QByteArray(svg_bytes))
if justification == "justify_fill":
svg.setAspectRatioMode(Qt.AspectRatioMode.IgnoreAspectRatio)
svg_rect = rect
svg.render(painter, svg_rect)
return
svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio)
svg_size = svg.defaultSize()
try:
svg_aspect = svg_size.width() / svg_size.height()
except ZeroDivisionError:
svg_aspect = 1
try:
rect_aspect = rect.width() / rect.height()
except ZeroDivisionError:
rect_aspect = 1
if svg_aspect > rect_aspect:
# svg is wider than rect
svg_width = rect.width()
svg_height = rect.width() / svg_aspect
else:
# svg is taller than rect
svg_width = rect.height() * svg_aspect
svg_height = rect.height()
svg_rect = self._get_aligned_image_rect(rect, index,
svg_width, svg_height)
if svg_rect is None:
return
svg.render(painter, svg_rect)
[docs]
def _render_matplotlib(self, painter: QPainter, rect: QRectF,
index: QModelIndex):
"""Matplotlib renderer
:param painter: Painter with which the matplotlib image is rendered
:param rect: Cell rect of the cell to be painted
:param index: Index of cell for which the matplotlib image is rendered
"""
if matplotlib is None:
# matplotlib is not installed
return
key = index.row(), index.column(), self.grid.table
figure = self.code_array[key]
if isinstance(figure, bytes) or isinstance(figure, str):
# We try rendering the content as SVG
return self._render_svg(painter, rect, index, figure)
if not isinstance(figure, matplotlib.figure.Figure):
return
# Save SVG in a fake file object.
with BytesIO() as filelike:
try:
figure.savefig(filelike, format="svg", bbox_inches="tight")
except Exception:
return
svg_str = filelike.getvalue().decode()
self._render_svg(painter, rect, index, svg_str=svg_str)
[docs]
def paint_(self, painter: QPainter, rect: QRectF,
option: QStyleOptionViewItem, index: QModelIndex):
"""Calls the overloaded paint function or creates html delegate
:param painter: Painter with which borders are drawn
:param rect: Cell rect of the cell to be painted
:param option: Style option for rendering
:param index: Index of cell for which borders are drawn
"""
painter.setRenderHints(QPainter.RenderHint.LosslessImageRendering
| QPainter.RenderHint.Antialiasing
| QPainter.RenderHint.TextAntialiasing
| QPainter.RenderHint.SmoothPixmapTransform)
key = index.row(), index.column(), self.grid.table
renderer = self.cell_attributes[key].renderer
old_rect = option.rect
option.rect = QRect(int(rect.x()), int(rect.y()),
int(rect.width() + 1.5),
int(rect.height() + 1.5))
if renderer == "text":
self._render_text(painter, rect, option, index)
elif renderer == "markup":
self._render_markup(painter, rect, option, index)
elif renderer == "image":
image = index.data(Qt.ItemDataRole.DecorationRole)
if isinstance(image, QImage):
self._render_qimage(painter, rect, index)
elif isinstance(image, str):
self._render_svg(painter, rect, index)
elif renderer == "matplotlib":
self._render_matplotlib(painter, rect, index)
option.rect = old_rect
[docs]
def sizeHint(self, option: QStyleOptionViewItem,
index: QModelIndex) -> QSize:
"""Overloads SizeHint
:param option: Style option for rendering
:param index: Index of the cell for the size hint
"""
key = index.row(), index.column(), self.grid.table
if not self.cell_attributes[key].renderer == "markup":
return super().sizeHint(option, index)
# HTML
options = QStyleOptionViewItem(option)
self.initStyleOption(options, index)
doc = QTextDocument()
doc.setHtml(options.text)
doc.setTextWidth(options.rect.width())
return QSize(doc.idealWidth(), doc.size().height())
[docs]
def paint(self, painter: QPainter, option: QStyleOptionViewItem,
index: QModelIndex):
"""Overloads `QStyledItemDelegate` to add cell border painting
:param painter: Painter with which borders are drawn
:param option: Style option for rendering
:param index: Index of cell to be rendered
"""
renderer = CellRenderer(self.grid, painter, option, index)
renderer.paint()
[docs]
def createEditor(self, parent: QWidget, option: QStyleOptionViewItem,
index: QModelIndex) -> QWidget:
"""Overloads `QStyledItemDelegate`
Disables editor in locked cells
Switches to chart dialog in chart cells
:param parent: Parent widget for the cell editor to be returned
:param option: Style option for the cell editor
:param index: Index of cell for which a cell editor is created
"""
key = index.row(), index.column(), self.grid.table
if self.cell_attributes[key].locked:
return
if self.cell_attributes[key].renderer == "matplotlib":
self.main_window.workflows.macro_insert_chart()
return
self.editor = super().createEditor(parent, option, index)
self.editor.setPalette(self.editor.style().standardPalette())
self.editor.installEventFilter(self)
return self.editor
[docs]
def eventFilter(self, source: QObject, event: QEvent) -> bool:
"""Overloads `eventFilter`. Overrides QLineEdit default shortcut.
Quotes cell editor content for <Ctrl>+<Enter> and <Ctrl>+<Return>.
Counts as undoable action.
:param source: Source widget of event
:param event: Any QEvent
"""
if event.type() == QEvent.Type.ShortcutOverride \
and source is self.editor \
and event.modifiers() == Qt.KeyboardModifier.ControlModifier \
and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
code = quote(source.text())
index = self.grid.currentIndex()
description = f"Quote code for cell {index}"
cmd = commands.SetCellCode(code, self.grid.model, index,
description)
self.main_window.undo_stack.push(cmd)
return super().eventFilter(source, event)
[docs]
def setEditorData(self, editor: QWidget, index: QModelIndex):
"""Overloads `setEditorData` to use code_array data
:param editor: Cell editor, in which data is set
:param index: Index of cell from which the cell editor data is set
"""
row = index.row()
column = index.column()
table = self.grid.table
value = self.code_array((row, column, table))
editor.setText(value)
[docs]
def setModelData(self, editor: QWidget, model: QAbstractItemModel,
index: QModelIndex):
"""Overloads `setModelData` to use code_array data
:param editor: Cell editor, from which data is retrieved
:param model: `GridTableModel`
:param index: Index of cell for which data is set
"""
description = f"Set code for cell {model.current(index)}"
command = commands.SetCellCode(editor.text(), model, index,
description)
self.main_window.undo_stack.push(command)
[docs]
def updateEditorGeometry(self, editor: QWidget,
option: QStyleOptionViewItem, _: QModelIndex):
"""Overloads `updateEditorGeometry` to update editor geometry to cell
:param editor: Cell editor, for which geometry is retrieved
:param option: Style option of the editor
"""
editor.setGeometry(option.rect)
[docs]
class TableChoice(QTabBar):
"""The TabBar below the main grid"""
def __init__(self, main_window: QMainWindow, no_tables: int):
"""
:param main_window: Application main window
:param no_tables: Number of tables to be initially created
"""
super().__init__(shape=QTabBar.Shape.RoundedSouth)
self.setExpanding(False)
self.main_window = main_window
self.no_tables = no_tables
self.last = 0
self.currentChanged.connect(self.on_table_changed)
@property
def no_tables(self) -> int:
"""Returns the number of tables in the table_choice"""
return self._no_tables
@no_tables.setter
def no_tables(self, value: int):
"""Sets the number of tables in the table_choice
:param value: Number of tables
"""
self._no_tables = value
if value > self.count():
# Insert
for i in range(self.count(), value):
self.addTab(str(i))
elif value < self.count():
# Remove
for i in range(self.count()-1, value-1, -1):
self.removeTab(i)
@property
def table(self) -> int:
"""Returns current table from table_choice that is displayed"""
return self.currentIndex()
@table.setter
def table(self, value: int):
"""Sets a new table to be displayed
:param value: Number of the table
"""
self.setCurrentIndex(value)
# Overrides
# Event handlers
[docs]
def on_table_changed(self, current: int):
"""Event handler for table changes
:param current: The current table to be displayed
"""
for grid in self.main_window.grids:
grid.table = current
grid.table_scrolls[self.last] = \
(grid.verticalScrollBar().value(),
grid.horizontalScrollBar().value())
with grid.undo_resizing_row():
with grid.undo_resizing_column():
grid.update_cell_spans()
grid.update_zoom()
grid.update_index_widgets()
grid.model.dataChanged.emit(QModelIndex(), QModelIndex())
grid.gui_update()
try:
v_pos, h_pos = grid.table_scrolls[current]
except KeyError:
v_pos = h_pos = 0
grid.verticalScrollBar().setValue(v_pos)
grid.horizontalScrollBar().setValue(h_pos)
self.last = current