# -*- 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/>.
# --------------------------------------------------------------------
# QPlainTextEdit With Inline Spell Check original code
#
# Original PyQt4 Version:
# https://nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/
#
# Copyright 2009 John Schember
# Copyright 2018 Stephan Sokolow
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
#
# Python Syntaxt highlighter original code
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# (1) Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# (2) Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the
# distribution.
#
# (3)The name of the author may not be used to
# endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT,
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Enchant Highlighter from John Schember; Stephan Sokolow
# MIT license --> GPL compatible
# PythonHighlighter from David Boddie
# Modified BSD license --> GPL compatible
"""
**Provides**
* :func:`format`
* :class:`LineNumberArea`
* :class:`SpellTextEdit`
* :class:`PythonEnchantHighlighter`
"""
import keyword
from math import log10
import re
import sys
from warnings import warn
try:
import enchant
from enchant import tokenize
from enchant.errors import TokenizerNotFoundError
except ImportError:
enchant = None
try:
# pylint: disable=ungrouped-imports
from enchant.utils import trim_suggestions
except ImportError: # Older versions of PyEnchant as on *buntu 14.04
# pylint: disable=unused-argument
[docs]
def trim_suggestions(word, suggs, maxlen, calcdist=None):
"""API Polyfill for earlier versions of PyEnchant."""
# TODO: Make this actually do some sorting
return suggs[:maxlen]
# pylint: disable=no-name-in-module
from PyQt6.QtCore import Qt, QEvent, QSize, QRect, QRectF
from PyQt6.QtGui import (QFocusEvent, QSyntaxHighlighter, QTextBlockUserData,
QTextCharFormat, QTextCursor, QColor, QFont, QAction,
QFontMetricsF, QPainter, QPalette, QActionGroup)
from PyQt6.QtWidgets import QApplication, QMenu, QPlainTextEdit, QWidget
try:
from pyspread.actions import SpellTextEditActions
except ImportError:
from actions import SpellTextEditActions
# Syntax styles that can be shared by all languages
STYLES = {
'keyword': format('blue'),
'operator': format('red'),
'brace': format('darkGray'),
'defclass': format('black', 'bold'),
'string': format('magenta'),
'string2': format('darkMagenta'),
'comment': format('darkGreen', 'italic'),
'self': format('black', 'italic'),
'numbers': format('brown'),
}
[docs]
class LineNumberArea(QWidget):
def __init__(self, parent: QPlainTextEdit):
"""
:param parent: Editor in which the line numbers shall be displayed
"""
super().__init__(parent)
self.parent = parent
[docs]
def sizeHint(self):
return QSize(self.parent.get_line_number_area_width(), 0)
[docs]
def paintEvent(self, event: QEvent):
"""Paint event called by parent"""
painter = QPainter(self)
palette = QPalette()
background_color = palette.color(QPalette.ColorRole.Window)
text_color = palette.color(QPalette.ColorRole.Text)
painter.fillRect(event.rect(), background_color)
block = self.parent.firstVisibleBlock()
block_number = block.blockNumber()
offset = self.parent.contentOffset()
top = self.parent.blockBoundingGeometry(block).translated(offset).top()
bottom = top + self.parent.blockBoundingRect(block).height()
height = self.parent.fontMetrics().horizontalAdvance("Tg")
while block.isValid() and (top <= event.rect().bottom()):
if block.isVisible() and (bottom >= event.rect().top()):
number = str(block_number + 1)
painter.setPen(text_color)
text_rect = QRectF(0, top, self.width(), height)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignRight,
number)
block = block.next()
top = bottom
bottom = top + self.parent.blockBoundingRect(block).height()
block_number += 1
[docs]
class SpellTextEdit(QPlainTextEdit):
"""QPlainTextEdit subclass which does spell-checking using PyEnchant"""
# Clamping value for words like "regex" which suggest so many things that
# the menu runs from the top to the bottom of the screen and spills over
# into a second column.
max_suggestions = 20
spaces_per_tab = 4
def __init__(self, parent=None, line_numbers=True,
font_family="Monospace"):
self.line_numbers = line_numbers
super().__init__()
self.actions = SpellTextEditActions(self)
# Set default font to font_family
font = self.document().defaultFont()
font.setFamily(font_family)
self.document().setDefaultFont(font)
# If a <Tab> is present then it should be of width 4
try:
_distance = QFontMetricsF(self.font()).horizontalAdvance(" ")
except AttributeError:
# PyQt6 version < 5.11
_distance = QFontMetricsF(
self.font()).boundingRect(" ").horizontalAdvance()
self.setTabStopDistance(_distance * self.spaces_per_tab)
# Line number area
self.line_number_area = LineNumberArea(self)
self.blockCountChanged.connect(self.update_line_number_area_width)
self.updateRequest.connect(self.update_line_number_area)
self.update_line_number_area_width()
self.show_line_numbers(self.line_numbers)
# Start with a default dictionary based on the current locale.
self.highlighter = PythonEnchantHighlighter(self.document())
if enchant is not None:
try:
self.highlighter.setDict(enchant.Dict())
except Exception as err:
# There are some weird enchant issues on different platforms.
# One of those has occured.
warn(str(err), ImportWarning)
[docs]
def show_line_numbers(self, visible: bool):
"""Show line number area if visible else hide it
:param visible: Line number area visibility
"""
if visible:
self.line_number_area.show()
else:
self.line_number_area.hide()
self.update_line_number_area_width()
[docs]
def get_line_number_area_width(self) -> int:
"""Returns width of line number area"""
if not self.line_number_area.isVisible():
return 0
margin = 3
digit_width = self.fontMetrics().horizontalAdvance('9')
digits = int(log10(max(1, self.blockCount()))) + 1
return margin + digit_width * digits
[docs]
def update_line_number_area_width(self):
"""Updates width of line_number_area"""
if self.line_number_area.isHidden():
return
self.setViewportMargins(self.get_line_number_area_width(), 0, 0, 0)
[docs]
def update_line_number_area(self, rect, dy):
"""Handle updates for line_number_area"""
if self.line_number_area.isHidden():
return
if dy:
self.line_number_area.scroll(0, dy)
else:
self.line_number_area.update(0, rect.y(),
self.line_number_area.width(),
rect.height())
if rect.contains(self.viewport().rect()):
self.update_line_number_area_width()
[docs]
def resizeEvent(self, event: QEvent):
"""Overides QPlainTextEdit.resizeEvent for handling line_number_area"""
super().resizeEvent(event)
crect = self.contentsRect()
line_number_area_rect = QRect(crect.left(), crect.top(),
self.get_line_number_area_width(),
crect.height())
try:
self.line_number_area.setGeometry(line_number_area_rect)
except AttributeError:
pass
[docs]
def keyPressEvent(self, event):
"""Overide to change tab into spaces_per_tab spaces"""
if event.key() == Qt.Key.Key_Tab:
self.insertPlainText(" " * self.spaces_per_tab)
else:
super().keyPressEvent(event)
[docs]
def cursorForMisspelling(self, pos):
"""Return a cursor selecting the misspelled word at ``pos`` or ``None``
This leverages the fact that QPlainTextEdit already has a system for
processing its contents in limited-size blocks to keep things fast.
"""
cursor = self.cursorForPosition(pos)
misspelled_words = getattr(cursor.block().userData(), 'misspelled', [])
# If the cursor is within a misspelling, select the word
for (start, end) in misspelled_words:
if start <= cursor.positionInBlock() <= end:
block_pos = cursor.block().position()
cursor.setPosition(block_pos + start,
QTextCursor.MoveMode.MoveAnchor)
cursor.setPosition(block_pos + end,
QTextCursor.MoveMode.KeepAnchor)
break
if cursor.hasSelection():
return cursor
else:
return None
[docs]
def cb_correct_word(self, action): # pylint: disable=no-self-use
"""Event handler for 'Spelling Suggestions' entries."""
cursor, word = action.data()
cursor.beginEditBlock()
cursor.removeSelectedText()
cursor.insertText(word)
cursor.endEditBlock()
[docs]
def cb_set_language(self, action):
"""Event handler for 'Language' menu entries."""
lang = action.data()
self.highlighter.setDict(enchant.Dict(lang))
[docs]
def cb_set_format(self, action):
"""Event handler for 'Language' menu entries."""
chunkers = action.data()
self.highlighter.setChunkers(chunkers)
# TODO: Emit an event so this menu can trigger other things
[docs]
class PythonEnchantHighlighter(QSyntaxHighlighter):
"""QSyntaxHighlighter subclass which consults a PyEnchant dictionary"""
if enchant is not None:
tokenizer = None
token_filters = (tokenize.EmailFilter, tokenize.URLFilter)
enable_enchant = False
# Define the spellcheck style once and just assign it as necessary
# XXX: Does QSyntaxHighlighter.setFormat handle keeping this from
# clobbering styles set in the data itself?
err_format = QTextCharFormat()
err_format.setUnderlineColor(Qt.GlobalColor.red)
err_format.setUnderlineStyle(
QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
# Python keywords
keywords = keyword.kwlist
# Python operators
operators = [
'=',
# Comparison
'==', '!=', '<', '<=', '>', '>=',
# Arithmetic
r'\+', '-', r'\*', '/', '//', r'\%', r'\*\*',
# In-place
r'\+=', '-=', r'\*=', '/=', r'\%=',
# Bitwise
r'\^', r'\|', r'\&', r'\~', '>>', '<<',
]
# Python braces
braces = [
r'\{', r'\}', r'\(', r'\)', r'\[', r'\]',
]
def __init__(self, *args):
QSyntaxHighlighter.__init__(self, *args)
# Initialize private members
self._sp_dict = None
self._chunkers = []
# Multi-line strings (expression, flag, style)
# FIXME: The triple-quotes in these two lines will mess up the
# syntax highlighting from this point onward
self.tri_single = ("'''", 1, STYLES['string2'])
self.tri_double = ('"""', 2, STYLES['string2'])
rules = []
# Keyword, operator, and brace rules
rules += [(r'\b%s\b' % w, 0, STYLES['keyword']) for w in self.keywords]
rules += [(r'%s' % o, 0, STYLES['operator']) for o in self.operators]
rules += [(r'%s' % b, 0, STYLES['brace']) for b in self.braces]
# All other rules
rules += [
# 'self'
(r'\bself\b', 0, STYLES['self']),
# Double-quoted string, possibly containing escape sequences
(r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']),
# Single-quoted string, possibly containing escape sequences
(r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']),
# 'def' followed by an identifier
(r'\bdef\b\s*(\w+)', 1, STYLES['defclass']),
# 'class' followed by an identifier
(r'\bclass\b\s*(\w+)', 1, STYLES['defclass']),
# From '#' until a newline
(r'#[^\n]*', 0, STYLES['comment']),
# Numeric literals
(r'\b[+-]?[0-9]+[lL]?\b', 0, STYLES['numbers']),
(r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, STYLES['numbers']),
(r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0,
STYLES['numbers']),
]
# Build a regex for each pattern
self.rules = [(re.compile(pat, re.U), index, fmt)
for (pat, index, fmt) in rules]
[docs]
def match_multiline(self, text, delimiter, in_state, style):
"""Do highlighting of multi-line strings. ``delimiter`` should be a
regex for triple-single-quotes or triple-double-quotes, and
``in_state`` should be a unique integer to represent the corresponding
state changes when inside those strings. Returns True if we're still
inside a multi-line string when this function is finished.
"""
# If inside triple-single quotes, start at 0
if self.previousBlockState() == in_state:
start = 0
add = 0
# Otherwise, look for the delimiter on this line
else:
start = text.find(delimiter)
# Move past this match
add = len(delimiter)
# As long as there's a delimiter match on this line...
while start >= 0:
# Look for the ending delimiter
end = text.find(delimiter, start + add)
# Ending delimiter on this line?
if end >= add:
length = end - start + add + len(delimiter)
self.setCurrentBlockState(0)
# No; multi-line string
else:
self.setCurrentBlockState(in_state)
try:
text_length = text.length()
except AttributeError:
text_length = len(text)
length = text_length - start + add
# Apply formatting
self.setFormat(start, length, style)
# Look for the next match
start = text.find(delimiter, start + length)
# Return state if still inside a multi-line string, False otherwise
if self.currentBlockState() == in_state:
return in_state
else:
return False
[docs]
def chunkers(self):
"""Gets the chunkers in use"""
return self._chunkers
[docs]
def dict(self):
"""Gets the spelling dictionary in use"""
return self._sp_dict
[docs]
def setChunkers(self, chunkers):
"""Sets the list of chunkers to be used"""
self._chunkers = chunkers
self.setDict(self.dict())
# FIXME: Revert self._chunkers on failure to ensure consistent state
[docs]
def setDict(self, sp_dict):
"""Sets the spelling dictionary to be used"""
if enchant is None:
return
try:
self.tokenizer = tokenize.get_tokenizer(sp_dict.tag,
chunkers=self._chunkers,
filters=self.token_filters)
except TokenizerNotFoundError:
# Fall back to the "good for most euro languages" English tokenizer
self.tokenizer = tokenize.get_tokenizer(
chunkers=self._chunkers, filters=self.token_filters)
self._sp_dict = sp_dict
self.rehighlight()
[docs]
def highlightBlock_enchant(self, text):
"""Method for pyenchant spell checker"""
if not self._sp_dict:
return
# Build a list of all misspelled words and highlight them
misspellings = []
for (word, pos) in self.tokenizer(text):
if not self._sp_dict.check(word):
self.setFormat(pos, len(word), self.err_format)
misspellings.append((pos, pos + len(word)))
# Store the list so the context menu can reuse this tokenization pass
# (Block-relative values so editing other blocks won't invalidate them)
data = QTextBlockUserData()
data.misspelled = misspellings
self.setCurrentBlockUserData(data)
[docs]
def highlightBlock_python(self, text):
"""Method for Python highlighter"""
# Do other syntax formatting
for expression, nth, format in self.rules:
for match in expression.finditer(text):
length = match.end() - match.start()
self.setFormat(match.start(), length, format)
# index = expression.indexIn(text, 0)
# while index >= 0:
# # We actually want the index of the nth match
# index = expression.pos(nth)
# length = len(expression.cap(nth))
# self.setFormat(index, length, format)
# index = expression.indexIn(text, index + length)
self.setCurrentBlockState(0)
# Do multi-line strings
in_multiline = self.match_multiline(text, *self.tri_single)
in_multiline = self.match_multiline(text, *self.tri_double)
[docs]
def highlightBlock(self, text):
"""Overridden QSyntaxHighlighter method to apply the highlight"""
self.highlightBlock_python(text)
if enchant is not None and self.enable_enchant:
self.highlightBlock_enchant(text)
if __name__ == '__main__':
app = QApplication(sys.argv)
spellEdit = SpellTextEdit()
spellEdit.show()
sys.exit(app.exec())