Source code for grid_renderer

# -*- 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/>.
# --------------------------------------------------------------------

"""

**Provides**

 * :func: `painter_save`: Context manager saving and restoring painter state
 * :func: `painter_zoom`: Context manager scaling and restoring the painter
 * :func: `painter_rotate`: Context manager rotating and restoring the painter
 * :class:`GridCellNavigator`: Find neighbors of a cell
 * :class:`EdgeBorders`: Dataclass for edge properties
 * :class:`CellEdgeRenderer`: Paints cell edges
 * :class:`QColorCache`: QColor cache
 * :class:`CellRenderer`: Paints cells

"""

from contextlib import contextmanager
try:
    from dataclasses import dataclass
except ImportError:
    from pyspread.lib.dataclasses import dataclass  # Python 3.6 compatibility
from typing import List, Tuple

from PyQt5.QtCore import Qt, QModelIndex, QRectF, QPointF
from PyQt5.QtGui import (QBrush, QColor, QPainter, QPalette, QPen,
                         QPainterPath, QPolygonF, QPainterPathStroker)
from PyQt5.QtWidgets import QTableView, QStyleOptionViewItem


[docs]@contextmanager def painter_save(painter: QPainter): """Context manager saving and restoring painter state :param painter: Painter, for which the state is preserved """ painter.save() yield painter.restore()
[docs]@contextmanager def painter_zoom(painter: QPainter, zoom: float, rect: QRectF) -> QRectF: """Context manager scaling and restoring the painter (rect.x(), rect.y()) is invariant :param painter: Painter, for which the state is preserved :param zoom: Zoom factor :param rect: Rect for setting zoom invariant point (rect.x(), rect.y()) """ with painter_save(painter): painter.translate(rect.x(), rect.y()) painter.scale(zoom, zoom) painter.translate(-rect.x() * zoom, -rect.y() * zoom) yield QRectF(rect.x() * zoom, rect.y() * zoom, rect.width() / zoom, rect.height() / zoom)
[docs]@contextmanager def painter_rotate(painter: QPainter, rect: QRectF, angle: int = 0) -> QRectF: """Context manager rotating and restoring the painter :param painter: Painter, which is rotated :param rect: Rect to be painted in :param angle: Rotataion angle must be in (0, 90, 180, 270) """ supported_angles = 0, 90, 180, 270 angle = int(angle) if angle not in supported_angles: msg = "Rotation angle {} not in {}".format(angle, supported_angles) raise Warning(msg) return center_x, center_y = rect.center().x(), rect.center().y() with painter_save(painter): painter.translate(center_x, center_y) painter.rotate(angle) if angle in (0, 180): painter.translate(-center_x, -center_y) elif angle in (90, 270): painter.translate(-center_y, -center_x) rect = QRectF(rect.y(), rect.x(), rect.height(), rect.width()) yield rect
[docs]class GridCellNavigator: """Find neighbors of a cell""" def __init__(self, grid: QTableView, key: Tuple[int, int, int]): """ :param grid: The main grid widget :param key: Key of cell fow which neighbors are identified """ self.grid = grid self.code_array = grid.model.code_array self.row, self.column, self.table = self.key = key @property def borderwidth_bottom(self) -> float: """Width of bottom border line""" return self.code_array.cell_attributes[self.key].borderwidth_bottom @property def borderwidth_right(self) -> float: """Width of right border line""" return self.code_array.cell_attributes[self.key].borderwidth_right @property def border_color_bottom(self) -> QColor: """Color of bottom border line""" return self.code_array.cell_attributes[self.key].bordercolor_bottom @property def border_color_right(self) -> QColor: """Color of right border line""" return self.code_array.cell_attributes[self.key].bordercolor_right @property def merge_area(self) -> Tuple[int, int, int, int]: """Merge area of the key cell""" return self.code_array.cell_attributes[self.key].merge_area
[docs] def _merging_key(self, key: Tuple[int, int, int]) -> Tuple[int, int, int]: """Merging cell if cell is merged else cell key :param key: Key of cell that is checked for being merged """ merging_key = self.code_array.cell_attributes.get_merging_cell(key) return key if merging_key is None else merging_key
[docs] def above_keys(self) -> List[Tuple[int, int, int]]: """Key list of neighboring cells above the key cell""" merge_area = self.merge_area if merge_area is None: return [self._merging_key((self.row - 1, self.column, self.table))] _, left, _, right = merge_area return [self._merging_key((self.row - 1, col, self.table)) for col in range(left, right + 1)]
[docs] def below_keys(self) -> List[Tuple[int, int, int]]: """Key list of neighboring cells below the key cell""" merge_area = self.merge_area if merge_area is None: return [self._merging_key((self.row + 1, self.column, self.table))] _, left, _, right = merge_area return [self._merging_key((self.row + 1, col, self.table)) for col in range(left, right + 1)]
[docs] def left_keys(self) -> List[Tuple[int, int, int]]: """Key list of neighboring cells left of the key cell""" merge_area = self.merge_area if merge_area is None: return [self._merging_key((self.row, self.column - 1, self.table))] top, _, bottom, _ = merge_area return [self._merging_key((row, self.column - 1, self.table)) for row in range(top, bottom + 1)]
[docs] def right_keys(self) -> List[Tuple[int, int, int]]: """Key list of neighboring cells right of the key cell""" merge_area = self.merge_area if merge_area is None: return [self._merging_key((self.row, self.column + 1, self.table))] top, _, bottom, _ = merge_area return [self._merging_key((row, self.column + 1, self.table)) for row in range(top, bottom + 1)]
[docs] def above_left_key(self) -> Tuple[int, int, int]: """Key of neighboring cell above left of the key cell""" return self._merging_key((self.row - 1, self.column - 1, self.table))
[docs] def above_right_key(self) -> Tuple[int, int, int]: """Key of neighboring cell above right of the key cell""" return self._merging_key((self.row - 1, self.column + 1, self.table))
[docs] def below_left_key(self) -> Tuple[int, int, int]: """Key of neighboring cell below left of the key cell""" return self._merging_key((self.row + 1, self.column - 1, self.table))
[docs] def below_right_key(self) -> Tuple[int, int, int]: """Key of neighboring cell below right of the key cell""" return self._merging_key((self.row + 1, self.column + 1, self.table))
[docs]@dataclass class EdgeBorders: """Holds border data for an edge and provides effective edge properties""" left_width: float right_width: float top_width: float bottom_width: float left_color: QColor right_color: QColor top_color: QColor bottom_color: QColor left_x: float right_x: float top_y: float bottom_y: float @property def _border_widths(self) -> Tuple[float, float, float, float]: """Tuple of border widths in order left, right, top, bottom""" return (self.left_width, self.right_width, self.top_width, self.bottom_width) @property def _border_colors(self) -> Tuple[QColor, QColor, QColor, QColor]: """Tuple of border colors in order left, right, top, bottom""" return (self.left_color, self.right_color, self.top_color, self.bottom_color) @property def width(self) -> float: """Edge width, i.e. thickest vertical border""" return max(self.top_width, self.bottom_width) @property def height(self) -> float: """Edge height, i.e. thickest horizontal border""" return max(self.left_width, self.right_width) @property def color(self) -> QColor: """Edge color, i.e. darkest color of thickest edge border""" max_border_width = max(self.width, self.height) colors = [] for width, color in zip(self._border_widths, self._border_colors): if width == max_border_width: colors.append(color) colors.sort(key=lambda color: color.lightnessF()) return colors[0]
[docs]class CellEdgeRenderer: """Paints cell edges""" def __init__(self, painter: QPainter, center: QPointF, borders: EdgeBorders, clip_path: QPainterPath, zoom: float): """ Borders are provided by EdgeBorders in order: left, right, top, bottom :param painter: Painter with which edge is drawn :param center: Edge center :param borders: Border widths and colors :param clip_path: Clip rectangle that is requuired for QtSVG clipping :param zoom: Current zoom level """ self.painter = painter self.center = center self.borders = borders self.clip_path = clip_path self.zoom = zoom
[docs] def paint(self): """Paints the edge""" if not self.borders.width or not self.borders.height: return # Invisible edge x, y = self.center.x(), self.center.y() width = self.borders.width * self.zoom height = self.borders.height * self.zoom rect = QRectF(x-width/2, y-height/2, width, height) rect_path = QPainterPath() # Required for clipping in SVG export rect_path.addRect(rect) color = self.borders.color self.painter.setPen(QPen(Qt.NoPen)) self.painter.setBrush(QBrush(color)) self.painter.drawPath(self.clip_path.intersected(rect_path)) self.painter.setPen(QPen(Qt.SolidLine))
[docs]class QColorCache(dict): """QColor cache that returns default color for None""" def __init__(self, grid, *args, **kwargs): self.grid = grid super().__init__(*args, **kwargs) def __missing__(self, key): if key is None: self[key] = qcolor = self.grid.palette().color(QPalette.Mid) else: self[key] = qcolor = QColor(*key) return qcolor
[docs]class CellRenderer: """Paints cells Cell rendering governs the area of a cell inside its borders. It is done in a vector oriented way. Therefore, the following conventions shall apply: * Cell borders of width 1 shall be painted so that they appear only in the bottom and right edge of the cell. * Cell borders of all widths have the same center line. * Cell borders that are thicker than 1 are painted on all borders. * At the edges, borders are painted in the following order: 1. Thin borders are painted first 2. If border width is equal, lighter colors are painted first """ def __init__(self, grid: QTableView, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): """ :param grid: The main grid widget :param painter: Painter with which borders are drawn :param option: Style option for rendering :param index: Index of cell to be rendered """ self.grid = grid self.painter = painter self.option = option self.index = index self.cell_attributes = grid.model.code_array.cell_attributes self.key = index.row(), index.column(), self.grid.table self.cell_nav = GridCellNavigator(grid, self.key) self.qcolor_cache = self.grid.qcolor_cache
[docs] def inner_rect(self, rect: QRectF) -> QRectF: """Returns inner rect that is shrunk by border widths For merged cells, minimum top/left border widths are taken into account :param rect: Cell rect to be shrunk """ above_keys = self.cell_nav.above_keys() left_keys = self.cell_nav.left_keys() width_top = min(self.cell_attributes[above_key].borderwidth_bottom for above_key in above_keys) width_left = min(self.cell_attributes[left_key].borderwidth_right for left_key in left_keys) width_bottom = self.cell_nav.borderwidth_bottom width_right = self.cell_nav.borderwidth_right width_top *= self.grid.zoom width_left *= self.grid.zoom width_bottom *= self.grid.zoom width_right *= self.grid.zoom rect_x = rect.x() + width_left / 2 rect_y = rect.y() + width_top / 2 rect_width = rect.width() - width_left / 2 - width_right / 2 rect_height = rect.height() - width_top / 2 - width_bottom / 2 return QRectF(rect_x, rect_y, rect_width, rect_height)
[docs] def paint_content(self, rect: QRectF): """ :param rect: Cell rect of the cell to be painted """ with painter_zoom(self.painter, self.grid.zoom, rect) as zrect: self.grid.delegate.paint_(self.painter, zrect, self.option, self.index)
[docs] def _get_border_pen(self, width, zoom): """Gets zoomed border pen :param width: Unzoomed line width :param zoom: Current zoom level of grid """ zoomed_width = max(1, width * zoom) return QPen(QColor(255, 255, 255, 0), zoomed_width, Qt.SolidLine, Qt.FlatCap, Qt.MiterJoin)
[docs] def paint_bottom_border(self, rect: QRectF, clip_path: QPainterPath): """Paint bottom border of cell :param rect: Cell rect of the cell to be painted :param clip_path: Clip rectangle that is required for QtSVG clipping """ if not self.cell_nav.borderwidth_bottom: return line_color = self.qcolor_cache[self.cell_nav.border_color_bottom] point1 = QPointF(rect.x(), rect.y() + rect.height()) point2 = QPointF(rect.x() + rect.width(), rect.y() + rect.height()) line_polygon = QPolygonF((point1, point2)) line_path = QPainterPath() line_path.addPolygon(line_polygon) pen = self._get_border_pen(self.cell_nav.borderwidth_bottom, self.grid.zoom) stroker = QPainterPathStroker(pen) stroked_path = stroker.createStroke(line_path) alpha = max(0, round(255 - 255 * self.cell_nav.borderwidth_bottom * self.grid.zoom)) self.painter.setPen(QPen(QColor(255, 255, 255, alpha))) self.painter.setBrush(QBrush(line_color)) self.painter.drawPath(clip_path.intersected(stroked_path))
[docs] def paint_right_border(self, rect: QRectF, clip_path: QPainterPath): """Paint right border of cell :param rect: Cell rect of the cell to be painted :param clip_path: Clip rectangle that is requuired for QtSVG clipping """ if not self.cell_nav.borderwidth_right: return line_color = self.qcolor_cache[self.cell_nav.border_color_right] point1 = QPointF(rect.x() + rect.width(), rect.y()) point2 = QPointF(rect.x() + rect.width(), rect.y() + rect.height()) line_polygon = QPolygonF((point1, point2)) line_path = QPainterPath() line_path.addPolygon(line_polygon) pen = self._get_border_pen(self.cell_nav.borderwidth_right, self.grid.zoom) stroker = QPainterPathStroker(pen) stroked_path = stroker.createStroke(line_path) alpha = max(0, round(255 - 255 * self.cell_nav.borderwidth_bottom * self.grid.zoom)) self.painter.setPen(QPen(QColor(255, 255, 255, alpha))) self.painter.setBrush(QBrush(line_color)) self.painter.drawPath(clip_path.intersected(stroked_path))
[docs] def paint_above_borders(self, rect: QRectF, clip_path: QPainterPath): """Paint lower borders of all above cells :param rect: Cell rect of below cell, in which the borders are painted :param clip_path: Clip rectangle that is requuired for QtSVG clipping """ for above_key in self.cell_nav.above_keys(): above_cell_nav = GridCellNavigator(self.grid, above_key) if not above_cell_nav.borderwidth_bottom: continue merge_area = above_cell_nav.merge_area if merge_area is None: columns = [above_key[1]] else: _, left, _, right = merge_area columns = list(range(left, right + 1)) above_rect_x = self.grid.columnViewportPosition(columns[0]) above_rect_width = sum(self.grid.columnWidth(column) for column in columns) line_color = self.qcolor_cache[above_cell_nav.border_color_bottom] point1 = QPointF(above_rect_x, rect.y()) point2 = QPointF(above_rect_x + above_rect_width, rect.y()) line_polygon = QPolygonF((point1, point2)) line_path = QPainterPath() line_path.addPolygon(line_polygon) pen = self._get_border_pen(above_cell_nav.borderwidth_bottom, self.grid.zoom) stroker = QPainterPathStroker(pen) stroked_path = stroker.createStroke(line_path) alpha = max(0, round(255 - 255 * above_cell_nav.borderwidth_bottom * self.grid.zoom)) self.painter.setPen(QPen(QColor(255, 255, 255, alpha))) self.painter.setBrush(QBrush(line_color)) self.painter.drawPath(clip_path.intersected(stroked_path))
[docs] def paint_left_borders(self, rect: QRectF, clip_path: QPainterPath): """Paint right borders of all left cells :param rect: Cell rect of right cell, in which the borders are painted :param clip_path: Clip rectangle that is requuired for QtSVG clipping """ for left_key in self.cell_nav.left_keys(): left_cell_nav = GridCellNavigator(self.grid, left_key) if not left_cell_nav.borderwidth_right: continue merge_area = left_cell_nav.merge_area if merge_area is None: rows = [left_key[0]] else: top, _, bottom, _ = merge_area rows = list(range(top, bottom + 1)) left_rect_y = self.grid.rowViewportPosition(rows[0]) left_rect_height = sum(self.grid.rowHeight(row) for row in rows) line_color = self.qcolor_cache[left_cell_nav.border_color_right] point1 = QPointF(rect.x(), left_rect_y) point2 = QPointF(rect.x(), left_rect_y + left_rect_height) line_polygon = QPolygonF((point1, point2)) line_path = QPainterPath() line_path.addPolygon(line_polygon) pen = self._get_border_pen(left_cell_nav.borderwidth_right, self.grid.zoom) stroker = QPainterPathStroker(pen) stroked_path = stroker.createStroke(line_path) alpha = max(0, round(255 - 255 * left_cell_nav.borderwidth_right * self.grid.zoom)) self.painter.setPen(QPen(QColor(255, 255, 255, alpha))) self.painter.setBrush(QBrush(line_color)) self.painter.drawPath(clip_path.intersected(stroked_path))
[docs] def paint_top_left_edge(self, rect: QRectF, clip_path: QPainterPath): """Paints top left edge of the cell :param rect: Cell rect of cell, for which the edge is painted :param clip_path: Clip rectangle that is requuired for QtSVG clipping top TL | T left ------------ right L | C bottom """ center = QPointF(rect.x(), rect.y()) top_left_key = self.cell_nav.above_left_key() left_key = self.cell_nav.left_keys()[0] top_key = self.cell_nav.above_keys()[0] top_left_cell_nav = GridCellNavigator(self.grid, top_left_key) left_cell_nav = GridCellNavigator(self.grid, left_key) top_cell_nav = GridCellNavigator(self.grid, top_key) left_width = top_left_cell_nav.borderwidth_bottom right_width = top_cell_nav.borderwidth_bottom top_width = top_left_cell_nav.borderwidth_right bottom_width = left_cell_nav.borderwidth_right left_color = self.qcolor_cache[top_left_cell_nav.border_color_bottom] right_color = self.qcolor_cache[top_cell_nav.border_color_bottom] top_color = self.qcolor_cache[top_left_cell_nav.border_color_right] bottom_color = self.qcolor_cache[left_cell_nav.border_color_right] left_x = rect.x() - rect.width() right_x = rect.x() top_y = rect.y() - rect.height() bottom_y = rect.y() borders = EdgeBorders(left_width, right_width, top_width, bottom_width, left_color, right_color, top_color, bottom_color, left_x, right_x, top_y, bottom_y) renderer = CellEdgeRenderer(self.painter, center, borders, clip_path, self.grid.zoom) renderer.paint()
[docs] def paint_top_right_edge(self, rect: QRectF, clip_path: QPainterPath): """Paints top right edge of the cell :param rect: Cell rect of cell, for which the edge is painted :param clip_path: Clip rectangle that is requuired for QtSVG clipping top T | TR left ------------ right C | R bottom """ center = QPointF(rect.x() + rect.width(), rect.y()) top_key = self.cell_nav.above_keys()[-1] top_right_key = self.cell_nav.above_right_key() top_cell_nav = GridCellNavigator(self.grid, top_key) top_right_cell_nav = GridCellNavigator(self.grid, top_right_key) left_width = top_cell_nav.borderwidth_bottom right_width = top_right_cell_nav.borderwidth_bottom top_width = top_cell_nav.borderwidth_right bottom_width = self.cell_nav.borderwidth_right left_color = self.qcolor_cache[top_cell_nav.border_color_bottom] right_color = self.qcolor_cache[top_right_cell_nav.border_color_bottom] top_color = self.qcolor_cache[top_cell_nav.border_color_right] bottom_color = self.qcolor_cache[self.cell_nav.border_color_right] left_x = rect.x() + rect.width() right_x = rect.x() + 2 * rect.width() top_y = rect.y() - rect.height() bottom_y = rect.y() borders = EdgeBorders(left_width, right_width, top_width, bottom_width, left_color, right_color, top_color, bottom_color, left_x, right_x, top_y, bottom_y) renderer = CellEdgeRenderer(self.painter, center, borders, clip_path, self.grid.zoom) renderer.paint()
[docs] def paint_bottom_left_edge(self, rect: QRectF, clip_path: QPainterPath): """Paints bottom left edge of the cell :param rect: Cell rect of cell, for which the edge is painted :param clip_path: Clip rectangle that is requuired for QtSVG clipping top L | C left ------------ right BL | B bottom """ center = QPointF(rect.x(), rect.y() + rect.height()) left_key = self.cell_nav.left_keys()[-1] bottom_left_key = self.cell_nav.below_left_key() left_cell_nav = GridCellNavigator(self.grid, left_key) bottom_left_cell_nav = GridCellNavigator(self.grid, bottom_left_key) left_width = left_cell_nav.borderwidth_bottom right_width = self.cell_nav.borderwidth_bottom top_width = left_cell_nav.borderwidth_right bottom_width = bottom_left_cell_nav.borderwidth_right left_color = self.qcolor_cache[left_cell_nav.border_color_bottom] right_color = self.qcolor_cache[self.cell_nav.border_color_bottom] top_color = self.qcolor_cache[left_cell_nav.border_color_right] bottom_color = \ self.qcolor_cache[bottom_left_cell_nav.border_color_right] left_x = rect.x() - rect.width() right_x = rect.x() top_y = rect.y() + rect.height() bottom_y = rect.y() + 2 * rect.height() borders = EdgeBorders(left_width, right_width, top_width, bottom_width, left_color, right_color, top_color, bottom_color, left_x, right_x, top_y, bottom_y) renderer = CellEdgeRenderer(self.painter, center, borders, clip_path, self.grid.zoom) renderer.paint()
[docs] def paint_bottom_right_edge(self, rect: QRectF, clip_path: QPainterPath): """Paints bottom right edge of the cell :param rect: Cell rect of cell, for which the edge is painted :param clip_path: Clip rectangle that is requuired for QtSVG clipping top C | R left ----------- right B | BR bottom """ center = QPointF(rect.x() + rect.width(), rect.y() + rect.height()) right_key = self.cell_nav.right_keys()[-1] bottom_key = self.cell_nav.below_keys()[-1] right_cell_nav = GridCellNavigator(self.grid, right_key) bottom_cell_nav = GridCellNavigator(self.grid, bottom_key) left_width = self.cell_nav.borderwidth_bottom right_width = right_cell_nav.borderwidth_bottom top_width = self.cell_nav.borderwidth_right bottom_width = bottom_cell_nav.borderwidth_right left_color = self.qcolor_cache[self.cell_nav.border_color_bottom] right_color = self.qcolor_cache[right_cell_nav.border_color_bottom] top_color = self.qcolor_cache[self.cell_nav.border_color_right] bottom_color = self.qcolor_cache[bottom_cell_nav.border_color_right] left_x = rect.x() + rect.width() right_x = rect.x() + 2 * rect.width() top_y = rect.y() + rect.height() bottom_y = rect.y() + 2 * rect.height() borders = EdgeBorders(left_width, right_width, top_width, bottom_width, left_color, right_color, top_color, bottom_color, left_x, right_x, top_y, bottom_y) renderer = CellEdgeRenderer(self.painter, center, borders, clip_path, self.grid.zoom) renderer.paint()
[docs] def paint_borders(self, rect): """Paint cell borders""" clip_path = QPainterPath() # Required for clipping in SVG export clip_path.addRect(rect) self.painter.save() self.paint_bottom_border(rect, clip_path) self.paint_right_border(rect, clip_path) self.paint_above_borders(rect, clip_path) self.paint_left_borders(rect, clip_path) self.paint_top_left_edge(rect, clip_path) self.paint_top_right_edge(rect, clip_path) self.paint_bottom_left_edge(rect, clip_path) self.paint_bottom_right_edge(rect, clip_path) self.painter.restore()
[docs] def paint(self): """Paints the cell""" rect = QRectF(self.option.rect) with painter_save(self.painter): self.painter.setClipRect(self.option.rect) angle = self.cell_attributes[self.key].angle inner_rect = self.inner_rect(rect) with painter_rotate(self.painter, inner_rect, angle) as rrect: self.paint_content(rrect) self.paint_borders(rect)