Source code for eye.widgets.editor

# this project is licensed under the WTFPLv2, see COPYING.txt for details

"""Editor widget


.. _positions:

Positions
---------

Positions in the text of an editor widget can be expressed in multiple ways.

First, the position of a character can be expressed as "line-index", which is the line and column of
that character, in terms of Unicode codepoints, with the `str` type.
Unless specified otherwise, line and column numbers start at 0 in EYE.

Another way, more low-level, is the byte offset of the byte in the byte text (with type `bytes`).
The internal byte encoding of the editor is UTF-8, regardless of the encoding of
the underlying disk file, which only intervenes when loading/saving.

Module contents
---------------
"""

from collections import namedtuple
import contextlib
from logging import getLogger
import os
import re
import unicodedata
from weakref import ref

from PyQt5.Qsci import QsciScintilla, QsciStyledText
from PyQt5.QtCore import Qt, QEvent
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QFileDialog, QMessageBox
import sip

from eye import structs, io
from eye.connector import disabled, register_event_filter
from eye.qt import Slot, Signal, override
from eye.widgets.helpers import CentralWidgetMixin, accept_if

__all__ = (
	'Editor', 'Marker', 'Indicator', 'Margin', 'BaseEditor', 'QsciScintilla', 'SciModification',
	'zoom_on_wheel'
)


LOGGER = getLogger(__name__)


class HasWeakEditorMixin:
	def __init__(self, editor=None, **kwargs):
		super().__init__(**kwargs)
		self.editor = editor

	@property
	def editor(self):
		if self.__editor is not None:
			return self.__editor()

	@editor.setter
	def editor(self, value):
		if value is None:
			self.__editor = None
		else:
			self.__editor = ref(value)


[docs] class Marker(HasWeakEditorMixin): """Margin marker of an editor Markers are graphical symbols that can be added in the margin of editor widgets. For example, a marker can be used to indicate a breakpoint is present on a particular line of the file. In an editor, a Marker can be set or unset for multiple lines, in which cases the configured symbol will be shown in the margin of the lines where the marker has been set. Example:: marker = editor.create_marker('breakpoint', editor.Circle) # declare a marker type called 'breakpoint' which will show a circle in the margin # the Marker instance can be retrieved if needed # marker = editor.markers['breakpoint'] marker.putAt(2) # marker is added at 3rd line marker.putAt(20) # marker is added at 21st line A `Marker` is associated with an :any:`eye.widgets.editor.Editor`. An `Editor` can have multiple `Marker`s, each with an arbitrary name. A `Marker` has a symbol or pixmap configured and can then be put or removed for individual lines of the associated `Editor`. .. TODO max number, internal id """ def __init__(self, sym, editor=None, id=-1): super().__init__(editor=editor) self.sym = sym self.id = id if editor: self._create()
[docs] def to_bit(self): """Return the internal Scintilla marker id in this editor instance""" return 1 << self.id
def _create(self, editor=None): if not self.editor: self.editor = editor if self.id < 0: if len(getattr(self.editor, 'free_markers', [])): self.id = self.editor.free_markers.pop() self.id = self.editor.markerDefine(self.sym, self.id) del self.sym
[docs] def set_symbol(self, param): """Change the visual symbol of the marker""" newid = self.editor.markerDefine(param, self.id) assert newid == self.id
[docs] def put_at(self, line): """Add a marker symbol of this type at `line`""" return self.editor.markerAdd(line, self.id)
[docs] def remove_at(self, line): """Remove marker of this type at `line` if present""" self.editor.markerDelete(line, self.id)
[docs] def toggle_at(self, line): """Toggle marker of this type at `line`""" if self.is_at(line): self.remove_at(line) else: self.put_at(line)
[docs] def is_at(self, line): """Return `True` if a marker of this type is present at `line`""" return self.to_bit() & self.editor.markersAtLine(line)
[docs] def get_next(self, line): """Return the line number of first line having this marker after `line` -1 is returned if there is no line with the marker after `line`. """ return self.editor.get_marker_next(line + 1, self.to_bit())
[docs] def get_previous(self, line): """Return the line number of first line having this marker before `line` -1 is returned if there is no line with the marker before `line`. """ return self.editor.get_marker_previous(line - 1, self.to_bit())
[docs] def list_all(self): """List all lines that have this marker set""" ln = -1 while True: ln = self.editor.marker_find_next(ln + 1, self.to_bit()) if ln < 0: return yield ln
[docs] def set_background_color(self, color): """Set background color of this marker type""" self.editor.setMarkerBackgroundColor(color, self.id)
[docs] def set_color(self, color): """Set foreground color of this marker type""" self.editor.setMarkerForegroundColor(color, self.id)
[docs] class Indicator(HasWeakEditorMixin): """Text indicator An indicator styles parts of the text with some particular visual style. It can be used for example by a spellchecker to underline misspelled words, or to highlight search results. In an editor, an indicator can be set for multiple ranges of characters in the text content, which will then be displayed in the configured style. Additionally, a numeric value can be associated when putting the indicator on a range. This allows to do some kind of sub-indicators. Where the indicator is not set, the value is always 0. The default value where an indicator is set is 1. Example: indic = editor.create_indicator('highlight', editor.BoxIndicator) # declare an indicator named 'highlight' with a "box" style (the text will be surrounded by a box) # the Indicator instance can be retrieved later: # indic = editor.indicators['highlight'] indic.putAt(0, 0, 1, 0) # the first line will be styled with this indicator Like :any:`eye.widgets.editor.Marker`, `Indicator`s are associated to an `Editor` and have an arbitrary name. There can be at most 40 different indicator types per editor widget. """ def __init__(self, style, editor=None, id=-1): super().__init__(editor=editor) self.style = style self.id = id if editor: self._create() def _create(self, editor=None): if not self.editor: self.editor = editor if self.id < 0: if len(getattr(self.editor, 'free_indicators', [])): self.id = self.editor.free_indicators.pop() self.id = self.editor.indicatorDefine(self.style, self.id) del self.style
[docs] def get_at_offset(self, offset): """Return the value of the indicator is present at byte `offset` If the indicator is not set at byte `offset`, 0 is returned, else the value of the indicator at this offset is returned. """ return self.editor.indicator_value_at(self.id, offset)
[docs] def is_on_edge(self, offset): if offset == 0: return bool(self.get_at_offset(offset)) else: return self.get_at_offset(offset) != self.get_at_offset(offset - 1)
[docs] def get_previous_edge(self, offset): """Return the offset of the first edge of this indicator before `offset`. If `offset` is inside a range of characters with this indicator set, the start of the range is returned. The returned start is inclusive: it is the first offset in the range. If `offset` is outside, the end of the previous range before `offset` is returned. The returned end is exclusive: it's the first offset outside the range. If there is no range before, -1 is returned. Example:: >>> indicator.put_at_offset(4, 10) >>> indicator.get_previous_edge(12) 10 >>> indicator.get_previous_edge(10) 4 >>> indicator.get_previous_edge(4) -1 """ if offset > 0: offset -= 1 # in scintilla, 'end' always advances, but 'start' blocks... res = self.editor.indicator_start(self.id, offset) if res == 0 and not self.get_at_offset(0): return -1 return res
[docs] def get_previous_range(self, offset, expected=None): end = self.get_previous_edge(offset) if end < 0: return None while True: start = self.get_previous_edge(end) if start < 0: return None value = self.get_at_offset(start) if value and (expected is None or expected == value): return (start, end, value) end = start
[docs] def get_next_edge(self, offset): """Return the offset of the first edge of this indicator after `offset`. If `offset` is inside a range of characters with this indicator set, the end of the range is returned. The returned end is exclusive: it's the first offset outside the range. If `offset` is outside a range, the start of the next range after `offset` is returned. The returned start is inclusive: it is the first offset in the range. If there is no range after, -1 is returned. Example:: >>> indicator.put_at_offset(4, 10) >>> indicator.get_next_edge(0) 4 >>> indicator.get_next_edge(4) 10 >>> indicator.get_next_edge(10) -1 """ blen = self.editor.bytes_length() if offset == blen: return -1 res = self.editor.indicator_end(self.id, offset) if res == 0: # 0 is returned when indicator is never set return -1 elif res == blen and not self.get_at_offset(offset): # bytes_length() is returned after last range return -1 return res
[docs] def get_next_range(self, offset, expected=None): start = self.get_next_edge(offset) if start < 0: return None while True: end = self.get_next_edge(start) if end < 0: return None value = self.get_at_offset(start) if value and (expected is None or expected == value): return (start, end, value) start = end
[docs] def get_current_range(self, offset): val = self.get_at_offset(offset) prev = self.get_previous_edge(offset) if self.get_at_offset(prev) != val: prev = offset next = self.get_next_edge(offset) return (prev, next, val)
[docs] def iter_ranges(self): """Return (start, end, value) tuples listing the ranges where the indicator is set. Returns an iterator of `(start, end, value)` range tuple. For each tuple, `start` (inclusive) and `end` (exclusive) are byte offsets. `value` is the value of the indicator in this range. """ ed_end = self.editor.bytes_length() start = 0 value = self.get_at_offset(start) while start < ed_end: end = self.editor.indicator_end(self.id, start) if value > 0: yield (start, end, value) if end == 0: # the indicator is set nowhere break start = end value = self.get_at_offset(start)
[docs] def iter_lines(self): ed_end = self.editor.bytes_length() start = 0 value = self.get_at_offset(start) while start < ed_end: end = self.editor.indicator_end(self.id, start) if end == 0: # the indicator is set nowhere break if value > 0: linestart, _ = self.editor.lineIndexFromPosition(start) lineend, _ = self.editor.lineIndexFromPosition(end) yield from range(linestart, lineend + 1) start = self.editor.positionFromLineIndex(lineend + 1, 0) else: start = end value = self.get_at_offset(start)
[docs] def put_at(self, line_from, index_from, line_to, index_to, value=1): """Add the indicator to a range of characters (line-index based) The indicator is set from `(line_from, index_from)` (inclusive) to `(line_to, index_to)` (exclusive). In this range, the indicator will have `value`. """ self.editor.fillIndicatorRange(line_from, index_from, line_to, index_to, self.id, value)
[docs] def put_at_offset(self, start, end, value=1): """Add the indicator to a range of characters (byte offset based) :param start: start offset (inclusive) :param end: end offset (exclusive) :param value: in the range, indicator will have this value """ startl, startc = self.editor.lineIndexFromPosition(start) endl, endc = self.editor.lineIndexFromPosition(end) self.put_at(startl, startc, endl, endc, value)
[docs] def remove_at(self, line_from, index_from, line_to, index_to): """Remove the indicator from a range of characters (line-index based) The indicator is unset from `(lineFrom, indexFrom)` (inclusive) to `(lineTo, indexTo)` (exclusive). In this range, the indicator value will be reset to 0. """ self.editor.clearIndicatorRange(line_from, index_from, line_to, index_to, self.id)
[docs] def remove_at_offset(self, start, end): """Remove the indicator from a range of characters (byte offset based) In this range, the indicator value will be reset to 0. :param start: start offset (inclusive) :param end: end offset (exclusive) """ startl, startc = self.editor.lineIndexFromPosition(start) endl, endc = self.editor.lineIndexFromPosition(end) self.remove_at(startl, startc, endl, endc)
[docs] def clear(self): """Remove the indicator from all characters in the editor widget""" self.remove_at_offset(0, self.editor.bytes_length())
[docs] def set_color(self, col): """Set the color of the text marked by this indicator""" self.editor.setIndicatorForegroundColor(col, self.id)
[docs] def set_outline_color(self, col): """Set the outline color of the text marked by this indicator""" self.editor.setIndicatorOutlineColor(col, self.id)
[docs] def set_style(self, style): """Set the visual style of the text marked by this indicator :param style: the new visual style to use :type style: QsciScintilla.IndicatorStyle """ self.id = self.editor.indicatorDefine(style, self.id)
[docs] def set_flags(self, flags): self.editor.set_indicator_flags(self.id, flags)
[docs] def get_flags(self): return self.editor.indicator_flags(self.id)
[docs] class Margin(HasWeakEditorMixin):
[docs] @staticmethod def NumbersMargin(editor=None): return Margin(editor, id=0)
[docs] @staticmethod def SymbolMargin(editor=None): return Margin(editor, id=1)
[docs] @staticmethod def FoldMargin(editor=None): return Margin(editor, id=2)
def __init__(self, editor=None, id=3): super().__init__(editor=editor) self.id = id self.width = 0 self.visible = True def _create(self, editor=None): if self.editor is None: self.editor = editor if self.editor: self.width = self.editor.marginWidth(self.id)
[docs] def set_width(self, w): self.width = w if self.visible: self.show()
[docs] def set_marker_types(self, names): bits = 0 for name in names: bits |= self.editor.markers[name].to_bit() self.editor.setMarginMarkerMask(self.id, bits)
[docs] def set_all_marker_types(self): self.editor.setMarginMarkerMask(self.id, (1 << 32) - 1)
[docs] def set_text(self, line, txt): if isinstance(txt, (str, bytes)): self.editor.setMarginText(self.id, txt, 0) else: self.editor.setMarginText(self.id, txt)
[docs] def show(self): self.visible = True self.editor.setMarginWidth(self.id, self.width)
[docs] def hide(self): self.visible = False self.editor.setMarginWidth(self.id, 0)
def sci_prop(prop, expected_args): def func(self, *args): if len(args) != len(expected_args): raise TypeError("this function takes exactly %d argument(s)" % len(expected_args)) for n, (arg, expected_type) in enumerate(zip(args, expected_args)): if not isinstance(arg, expected_type): raise TypeError("argument %d has unexpected type %r (expected %r)" % (n + 1, type(arg).__name__, expected_type.__name__)) return self.SendScintilla(prop, *args) return func def sci_prop_2(prop): def func(self, arg1, arg2): return self.SendScintilla(prop, arg1, arg2) return func def sci_prop_set(prop): def func(self, value): return self.SendScintilla(prop, value) return func sci_prop_1 = sci_prop_set def sci_prop_get(prop): def func(self): return self.SendScintilla(prop) return func sci_prop_0 = sci_prop_get def sipvoid_as_str(v): i = 1 while True: s = v.asstring(i) if s[-1] == '\x00': return s[:-1] i += 1 SciModification = namedtuple( 'SciModification', ( 'position', 'modificationType', 'text', 'length', 'linesAdded', 'line', 'foldLevelNow', 'foldLevelPrev', 'token', 'annotationLinesAdded' ) ) _SelectionTuple = namedtuple( 'Selection', ('anchor_line', 'anchor_index', 'caret_line', 'caret_index') ) class Selection(_SelectionTuple): @property def anchor(self): return (self.anchor_line, self.anchor_index) @property def caret(self): return (self.caret_line, self.caret_index) @property def start(self): return min(self.anchor, self.caret) @property def end(self): return max(self.anchor, self.caret)
[docs] class BaseEditor(QsciScintilla): """Editor class adding missing Scintilla features QsciScintilla is an incomplete wrapper to Scintilla, this class aims to add support for a few of the missing editor features. .. note:: This class should not be instanciated directly as it exists only to add editor widget features and is thus considered low-level. :any:`eye.widgets.editor.Editor` contains file-related features and should be used instead. .. seealso:: Since QsciScintilla is used as a base, the `QsciScintilla documentation <http://pyqt.sourceforge.net/Docs/QScintilla2/classQsciScintilla.html>`_ should also be consulted. The more low-level `Scintilla documentation <http://www.scintilla.org/ScintillaDoc.html>`_ can also help, though more rarely. """ # selection SelectionStream = QsciScintilla.SC_SEL_STREAM """Select a character stream between two offsets in the text. If the start offset and end offset are not on the same lines, the characters from the start offset to the end of its line are selected, plus the characters from the end offset to the start of its line, plus the lines in between are completely selected. """ SelectionRectangle = QsciScintilla.SC_SEL_RECTANGLE """Select characters in a rectangle between two offsets in the text. On each line from the line of the start offset to the line of the end offset, only characters from the column of the start offset to end column of the end offset are selected, thus making a rectangle. """ SelectionLines = QsciScintilla.SC_SEL_LINES """Select full lines between two offsets in the text. All characters of the lines between the start offset and end offset, included, are selected. """ SelectionThin = QsciScintilla.SC_SEL_THIN set_selection_mode = sci_prop_set(QsciScintilla.SCI_SETSELECTIONMODE) selection_mode = sci_prop_get(QsciScintilla.SCI_GETSELECTIONMODE) set_multiple_selection = sci_prop_set(QsciScintilla.SCI_SETMULTIPLESELECTION) """setMultipleSelection(bool) Set if multiple ranges of characters can be selected. All ranges are selected in the same selection mode. """ multiple_selection = sci_prop_0(QsciScintilla.SCI_GETMULTIPLESELECTION) """Return `True` if multiple selection is enabled""" set_additional_selection_typing = sci_prop(QsciScintilla.SCI_SETADDITIONALSELECTIONTYPING, (bool,)) """Set whether typing in a multi-selection should type in all selections. If set to `True`, when multiple regions are selected, typing or removing characters will act on all selections instead of the main selection only. """ additional_selection_typing = sci_prop_0(QsciScintilla.SCI_GETADDITIONALSELECTIONTYPING) """Return True if typing operates on all selections. See :any:`set_additional_selection_typing`. """ selections_count = sci_prop_get(QsciScintilla.SCI_GETSELECTIONS) """Return the number of selection ranges (if multiple selections are enabled, else 1)""" selections_empty = sci_prop_0(QsciScintilla.SCI_GETSELECTIONEMPTY) """Return True if all selections are empty.""" clear_selections = sci_prop_0(QsciScintilla.SCI_CLEARSELECTIONS) """Deselect all selections.""" set_main_selection = sci_prop(QsciScintilla.SCI_SETMAINSELECTION, (int,)) """Set the index of the main selection. When there are multiple selections, set the main selection to be the n-th selection. """ main_selection = sci_prop_0(QsciScintilla.SCI_GETMAINSELECTION) """Return the main selection index."""
[docs] def add_selection(self, line_from, index_from, line_to, index_to): """Add a new selection (line-index based). The first selection should be set with :any:`setSelection`, and the next ones with this method. """ offset_from = self.positionFromLineIndex(line_from, index_from) offset_to = self.positionFromLineIndex(line_to, index_to) self.add_selection_offsets(offset_from, offset_to)
add_selection_offsets = sci_prop_2(QsciScintilla.SCI_ADDSELECTION) """Add a new selection (offset based). See :any:`add_selection`. """ drop_selection_n = sci_prop(QsciScintilla.SCI_DROPSELECTIONN, (int,)) """Deselect the n-th selection.""" selection_n_caret = sci_prop(QsciScintilla.SCI_GETSELECTIONNCARET, (int,)) """Get the offset of the n-th selection's caret.""" selection_n_anchor = sci_prop(QsciScintilla.SCI_GETSELECTIONNANCHOR, (int,)) """Get the offset of the n-th selection's anchor."""
[docs] def get_selection_n(self, n): """Get line-indexes of the n-th selection. Returns a 4-tuple with line-index of the anchor and line-index of the caret. Note that the caret may be before or after the anchor. :param n: index of the selection :type n: int :rtype: tuple[int, int, int, int] """ anchor = self.lineIndexFromPosition(self.selection_n_anchor(n)) caret = self.lineIndexFromPosition(self.selection_n_caret(n)) return Selection(anchor[0], anchor[1], caret[0], caret[1])
set_multi_paste = sci_prop_1(QsciScintilla.SCI_SETMULTIPASTE) """Set whether pasting in a multi-selection should paste in all selections If set to `True`, when multiple regions are selected, pasting will paste in all selections instead of the main selection only. """ multi_paste = sci_prop_0(QsciScintilla.SCI_GETMULTIPASTE) """Return True if pasting operates on all selections. See :any:`setMultiPaste`. """ # virtual space VsNone = QsciScintilla.SCVS_NONE """Virtual space after a line's end is not accessible""" VsRectangular = QsciScintilla.SCVS_RECTANGULARSELECTION """Virtual space after a line's end is accessible with rectangular selection mode""" VsUser = QsciScintilla.SCVS_USERACCESSIBLE """Virtual space after a line's end is accessible by user with cursor""" set_virtual_space_options = sci_prop_set(QsciScintilla.SCI_SETVIRTUALSPACEOPTIONS) """Set options for virtual space after a line's end Should be an or-combination of one or more flags in :any:`VsNone`, :any:`VsRectangular`, :any:`VsUser`. """ virtual_space_options = sci_prop_get(QsciScintilla.SCI_GETVIRTUALSPACEOPTIONS) """Get virtual space options See :any:`setVirtualSpaceOptions`. """ # character representation set_representation = sci_prop_2(QsciScintilla.SCI_SETREPRESENTATION)
[docs] def get_representation(self, s): bufsize = self.SendScintilla(self.SCI_GETREPRESENTATION, s, b'') + 1 if not bufsize: return [] res = bytearray(bufsize) self.SendScintilla(self.SCI_GETREPRESENTATION, s, res) return bytes(res[:-1])
[docs] def clear_representation(self, s): # for unknown reasons, s is passed as lParam instead of wParam, so force it self.SendScintilla(QsciScintilla.SCI_CLEARREPRESENTATION, s, b'')
# fold FoldFlagLineBeforeExpanded = QsciScintilla.SC_FOLDFLAG_LINEBEFORE_EXPANDED FoldFlagLineBeforeContracted = QsciScintilla.SC_FOLDFLAG_LINEBEFORE_CONTRACTED FoldFlagLineAfterExpanded = QsciScintilla.SC_FOLDFLAG_LINEAFTER_EXPANDED FoldFlagLineAfterContracted = QsciScintilla.SC_FOLDFLAG_LINEAFTER_CONTRACTED FoldFlagLevelNumbers = QsciScintilla.SC_FOLDFLAG_LEVELNUMBERS FoldFlagLineState = QsciScintilla.SC_FOLDFLAG_LINESTATE set_fold_flags = sci_prop_set(QsciScintilla.SCI_SETFOLDFLAGS) set_fold_level = sci_prop(QsciScintilla.SCI_SETFOLDLEVEL, (int, int)) """Set fold level of a line Set fold level `arg2` for line `arg1`. """ get_fold_level = sci_prop(QsciScintilla.SCI_GETFOLDLEVEL, (int,)) """Get fold level of line `value`""" # macro _start_macro_record = sci_prop_0(QsciScintilla.SCI_STARTRECORD) _stop_macro_record = sci_prop_0(QsciScintilla.SCI_STOPRECORD) # undo
[docs] def set_undo_collection(self, b): """set_undo_collection(bool): set whether editing actions are collected in the undo buffer""" self.SendScintilla(QsciScintilla.SCI_SETUNDOCOLLECTION, int(b))
undo_collection = sci_prop_0(QsciScintilla.SCI_GETUNDOCOLLECTION) """undo_collection(): return whether editing actions are collected in the undo buffer""" empty_undo_buffer = sci_prop_0(QsciScintilla.SCI_EMPTYUNDOBUFFER) """empty_undo_buffer(): empty the undo buffer""" add_undo_action = sci_prop_2(QsciScintilla.SCI_ADDUNDOACTION) """add_undo_action(int, int): add a custom action to the undo buffer""" # markers _get_marker_previous = sci_prop(QsciScintilla.SCI_MARKERPREVIOUS, (int, int)) _get_marker_next = sci_prop(QsciScintilla.SCI_MARKERNEXT, (int, int)) # indicators indicator_value_at = sci_prop(QsciScintilla.SCI_INDICATORVALUEAT, (int, int)) indicator_start = sci_prop(QsciScintilla.SCI_INDICATORSTART, (int, int)) indicator_end = sci_prop(QsciScintilla.SCI_INDICATOREND, (int, int)) _set_indicator_value = sci_prop(QsciScintilla.SCI_SETINDICATORVALUE, (int,)) _set_indicator_current = sci_prop(QsciScintilla.SCI_SETINDICATORCURRENT, (int,)) _fill_indicator_range = sci_prop(QsciScintilla.SCI_INDICATORFILLRANGE, (int, int)) set_indicator_flags = sci_prop_2(QsciScintilla.SCI_INDICSETFLAGS) indicator_flags = sci_prop_1(QsciScintilla.SCI_INDICGETFLAGS) IndicatorFlagValueFore = getattr(QsciScintilla, 'SC_INDICFLAG_VALUEFORE', 1) # search set_target_start = sci_prop(QsciScintilla.SCI_SETTARGETSTART, (int,)) target_start = sci_prop_0(QsciScintilla.SCI_GETTARGETSTART) set_target_end = sci_prop(QsciScintilla.SCI_SETTARGETEND, (int,)) target_end = sci_prop_0(QsciScintilla.SCI_GETTARGETEND) set_target_range = sci_prop(QsciScintilla.SCI_SETTARGETRANGE, (int, int)) _search_in_target = sci_prop(QsciScintilla.SCI_SEARCHINTARGET, (int, bytes)) replace_target = sci_prop_2(QsciScintilla.SCI_REPLACETARGET) set_search_flags = sci_prop_set(QsciScintilla.SCI_SETSEARCHFLAGS) search_flags = sci_prop_0(QsciScintilla.SCI_GETSEARCHFLAGS) # caret CaretStyleInvisible = QsciScintilla.CARETSTYLE_INVISIBLE """Caret is invisible""" CaretStyleLine = QsciScintilla.CARETSTYLE_LINE """Caret is a vertical line between two characters""" CaretStyleBlock = QsciScintilla.CARETSTYLE_BLOCK """Caret is a block enclosing the next character""" set_caret_style = sci_prop_set(QsciScintilla.SCI_SETCARETSTYLE) """Set caret display style Should be one of :any:`CaretStyleInvisible`, :any:`CaretStyleLine`, :any:`CaretStyleBlock`. """ caret_style = sci_prop_get(QsciScintilla.SCI_GETCARETSTYLE) """Get caret display style See :any:`set_caret_style`. """ set_caret_period = sci_prop_set(QsciScintilla.SCI_SETCARETPERIOD) """Set caret blinking period in milliseconds""" caret_period = sci_prop_get(QsciScintilla.SCI_GETCARETPERIOD) """Get caret blinking period in milliseconds""" # lexer set_lexer_property = sci_prop(QsciScintilla.SCI_SETPROPERTY, (bytes, bytes)) """set_lexer_property(bytes, bytes): set a lexer property (key/value)"""
[docs] def lexer_property(self, prop): bufsize = self.SendScintilla(QsciScintilla.SCI_GETPROPERTY, prop, None) + 1 if not bufsize: return [] res = bytearray(bufsize) self.SendScintilla(QsciScintilla.SCI_GETPROPERTY, prop, res) return bytes(res[:-1])
# text delete_range = sci_prop_2(QsciScintilla.SCI_DELETERANGE) """Delete characters in byte offset range""" insert_bytes = sci_prop(QsciScintilla.SCI_INSERTTEXT, (int, bytes)) """Insert byte characters at byte offset""" position_relative = sci_prop(QsciScintilla.SCI_POSITIONRELATIVE, (int, int)) """Get byte-offset from byte-offset + number of characters""" # style
[docs] def set_style_hotspot(self, style_id, b): """set_style_hotspot(int, bool): set whether a style is a hotspot (like a link)""" self.SendScintilla(QsciScintilla.SCI_STYLESETHOTSPOT, style_id, int(b))
get_style_hotspot = sci_prop(QsciScintilla.SCI_STYLEGETHOTSPOT, (int,)) """get_style_hotspot(int): get whether a style is a hotspot""" get_style_at = sci_prop_1(QsciScintilla.SCI_GETSTYLEAT) """get_style_at(int): get style number at given byte position""" get_position_from_point = sci_prop_2(QsciScintilla.SCI_POSITIONFROMPOINT) """get_position_from_point(int): """ get_position_from_point_close = sci_prop_2(QsciScintilla.SCI_POSITIONFROMPOINTCLOSE) """get_position_from_point_close(int, int): """ def __init__(self, **kwargs): super().__init__(**kwargs) self.SCN_MACRORECORD.connect(self.scn_macro) self.SCN_AUTOCCANCELLED.connect(self.scn_autoccancelled) self.free_markers = [] self.markers = {} self.free_indicators = [] self.indicators = {} self.margins = {} self.auto_comp_list_id = 0 self._counter_sci_modified = 0 self.create_margin('lines', Margin.NumbersMargin()) self.create_margin('folding', Margin.FoldMargin()) self.create_margin('symbols', Margin.SymbolMargin()) ## markers, indicators, margins def _create_mi(self, d, name, obj): if name in d: return d[name] d[name] = obj obj._create(editor=self) return obj
[docs] def create_marker(self, name, marker=QsciScintilla.Circle): """Create and return a Marker with name `name` and symbol `marker`""" if not isinstance(marker, Marker): marker = Marker(marker) return self._create_mi(self.markers, name, marker)
[docs] def create_indicator(self, name, indicator=QsciScintilla.PlainIndicator): """Create and return an Indicator with name `name` and style `indicator`""" if not isinstance(indicator, Indicator): indicator = Indicator(indicator) return self._create_mi(self.indicators, name, indicator)
[docs] def create_margin(self, name, margin): return self._create_mi(self.margins, name, margin)
def _dispose_mi(self, d, dfree, name): if name not in d: return dfree.append(d[name].id) del d[name]
[docs] def dispose_marker(self, name): self._dispose_mi(self.markers, self.free_markers, name)
[docs] def dispose_indicator(self, name): self._dispose_mi(self.indicators, self.free_indicators, name)
## indicators def _indicator_to_id(self, indicator): if isinstance(indicator, Indicator): return indicator.id elif isinstance(indicator, (str, bytes)): return self.indicators[indicator].id return indicator
[docs] def fillIndicatorRange(self, line_from, index_from, line_to, index_to, indic, value=1): indic = self._indicator_to_id(indic) if indic < 0: return QsciScintilla.fillIndicatorRange(self, line_from, index_from, line_to, index_to, indic) offset_start = self.positionFromLineIndex(line_from, index_from) offset_end = self.positionFromLineIndex(line_to, index_to) self._set_indicator_current(indic) self._set_indicator_value(value) self._fill_indicator_range(offset_start, offset_end - offset_start)
[docs] def clearIndicatorRange(self, line_from, index_from, line_to, index_to, indic): indic = self._indicator_to_id(indic) return QsciScintilla.clearIndicatorRange(self, line_from, index_from, line_to, index_to, indic)
## markers def _marker_to_id(self, marker): if isinstance(marker, (str, bytes)): return self.markers[marker].id elif isinstance(marker, Marker): return marker.id return marker
[docs] def markerAdd(self, line, marker): """Add marker with name/id `i` at line `ln`""" marker = self._marker_to_id(marker) return QsciScintilla.markerAdd(self, line, marker)
[docs] def markerDelete(self, line, marker): """Delete marker with name/id `i` from line `ln`""" marker = self._marker_to_id(marker) return QsciScintilla.markerDelete(self, line, marker)
[docs] def setMarkerBackgroundColor(self, color, marker): """Set background color `c` to marker with id/name `i`""" marker = self._marker_to_id(marker) return QsciScintilla.setMarkerBackgroundColor(self, color, marker)
[docs] def setMarkerForegroundColor(self, color, marker): marker = self._marker_to_id(marker) return QsciScintilla.setMarkerForegroundColor(self, color, marker)
[docs] def get_marker_previous(self, line, marker): marker = self._marker_to_id(marker) return self._get_marker_previous(line, marker)
[docs] def get_marker_next(self, line, marker): marker = self._marker_to_id(marker) return self._get_marker_next(line, marker)
## macros #~ @Slot('uint', 'unsigned long', object)
[docs] def scn_macro(self, msg, lp, wp): if isinstance(wp, sip.voidptr): self.action_recorded.emit([msg, lp, sipvoid_as_str(wp)]) else: self.action_recorded.emit([msg, lp, wp])
[docs] def start_macro_record(self): """Start recording macro Also emits `macro_record_started()` """ self._start_macro_record() self.macro_record_started.emit()
[docs] def stop_macro_record(self): """Stop recording macro Also emits `macro_record_stopped()` """ self._stop_macro_record() self.macro_record_stopped.emit()
[docs] def replay_macro_action(self, action): """Replay a macro action """ msg, lp, wp = action return self.SendScintilla(msg, lp, wp)
[docs] def search_in_target(self, s): if isinstance(s, str): s = s.encode('utf-8') return self._search_in_target(len(s), s)
## annotations
[docs] def annotation_styled_text(self, line): """Return styled text annotations of a line Each line can have annotations compound of multiple pieces of text styled differently. This method retrieves all text parts along with their styles of the annotations of `line`. It can be seen as the "get" counterpart of the :any:`annotate` function taking a list of :any:`QsciStyledText`. :rtype: list of `QsciStyledText` """ bufsize = self.SendScintilla(self.SCI_ANNOTATIONGETTEXT, line, 0) if not bufsize: return [] text = bytearray(bufsize) self.SendScintilla(self.SCI_ANNOTATIONGETTEXT, line, text) styles = bytearray(bufsize) self.SendScintilla(self.SCI_ANNOTATIONGETSTYLES, line, styles) oldn = 0 oldst = styles[0] res = [] for n, st in enumerate(styles): if oldst != st: part = text[oldn:n].decode('utf-8') res.append(QsciStyledText(part, oldst)) oldn = n oldst = st part = text[oldn:].decode('utf-8') res.append(QsciStyledText(part, oldst)) return res
[docs] @Slot(int, int, 'const char*', int, int, int, int, int, int, int) def scn_modified(self, *args): self.sci_modified.emit(SciModification(*args))
[docs] def connectNotify(self, sig): super().connectNotify(sig) if sig.name() == b'sci_modified': self._counter_sci_modified += 1 try: self.SCN_MODIFIED.connect(self.scn_modified, Qt.UniqueConnection) except TypeError: # prevent duplicating connection pass
[docs] def disconnectNotify(self, sig): super().disconnectNotify(sig) if not sig.isValid(): return if sig.name() == b'sci_modified': self._counter_sci_modified -= 1 assert self._counter_sci_modified >= 0 if not self._counter_sci_modified: self.SCN_MODIFIED.disconnect(self.scn_modified)
[docs] @Slot() def scn_autoccancelled(self): self.autoCompListId = 0
[docs] def showUserList(self, id, items): self.autoCompListId = id super().showUserList(id, items)
macro_record_started = Signal() """Signal macro_record_started() After this signal is emitted, and until `macro_record_stopped()` is emitted, actions performed by user will be recorded and `action_recorded(object)` will be emitted for each action. """ macro_record_stopped = Signal() """Signal macro_record_stopped() This signal is emitted when macro recording stops. `action_recorded()` will not be emitted any more after. """ action_recorded = Signal(object) """Signal action_recorded(object): an action was recorded in macro The signal argument is the action recorded, and can be passed to `replay_macro_action` to replay this action. Internally, the action argument is a tuple suitable for Scintilla to process it. """ sci_modified = Signal(object) """Signal sci_modified(object): a modification was done The signal argument is a 10-tuple describing the modification. The modifications signalled can be of various types. """
[docs] class Editor(BaseEditor, CentralWidgetMixin): """Editor widget class By default, instances of this class have the "editor" category set (see :doc:`eye.connector` for more info). .. seealso:: Since QsciScintilla is used as a base, the `QsciScintilla documentation <http://pyqt.sourceforge.net/Docs/QScintilla2/classQsciScintilla.html>`_ should also be consulted. """ SmartCaseSensitive = object() def __init__(self, **kwargs): super().__init__(**kwargs) self.path = '' self.modificationChanged.connect(self.setWindowModified) self.modificationChanged.connect(self._update_title) self._update_title() self.saving = structs.PropDict() self.saving.trim_whitespace = False self.saving.final_newline = True self.saving.encoding = 'utf-8' self.setUtf8(True) # the editor is in utf-8 internally, encoding is done when saving self.search = structs.PropDict() self.search.incremental = True self.search.highlight = False self.search.is_re = False self.search.case_sensitive = False self.search.wrap = True self.search.whole = False self._lexer = None self.setWindowIcon(QIcon()) self.add_category('editor') def __repr__(self): return '<Editor path=%r>' % self.path def _update_title(self): t = os.path.basename(self.path) or '<untitled>' if self.isModified(): t = '%s*' % t self.setWindowTitle(t) self.setToolTip(self.path or '<untitled>') ## file management def _get_filename(self): if not self.path: return '' return os.path.basename(self.path)
[docs] @Slot() def save_file(self): """Save edited file If no file path is set, a file dialog is shown to ask the user where to save content. """ path = self.path new_file = not path if new_file: path, qfilter = QFileDialog.getSaveFileName(self, self.tr('Save file'), os.path.expanduser('~')) if not path: return False data = self._write_text(self.text()) self.file_about_to_be_saved.emit(path) try: io.write_bytes_to_file(path, data) except OSError: LOGGER.error('cannot write file %r', path, exc_info=True) return False self.path = path self.setModified(False) if new_file: self.file_saved_as.emit(path) else: self.file_saved.emit(path) return True
[docs] def close_file(self): """Prepare for closing file and return `True` if modification state is clean If editor has no unsaved modifications, returns `True`. Else, ask user if modifications should be saved, then return `True` if accepted, else return `False`. """ ret = True if self.isModified(): file = self.windowTitle() answer = QMessageBox.question(self, self.tr('Unsaved file'), self.tr('%s has been modified, do you want to close it?') % file, QMessageBox.Discard | QMessageBox.Cancel | QMessageBox.Save) if answer == QMessageBox.Discard: ret = True elif answer == QMessageBox.Cancel: ret = False elif answer == QMessageBox.Save: ret = self.save_file() return ret
def _newline_string(self): modes = { QsciScintilla.SC_EOL_LF: '\n', QsciScintilla.SC_EOL_CR: '\r', QsciScintilla.SC_EOL_CRLF: '\r\n', } return modes.get(self.eolMode(), '\n') def _read_text(self, data): text = data.decode(self.saving.encoding) if self.saving.final_newline and text.endswith(self._newline_string()): text = text[:-1] return text def _remove_trailing_whitespace(self, text): return re.sub(r'[ \t]+$', '', text, flags=re.MULTILINE) def _write_text(self, text): if self.saving.trim_whitespace: text = self._remove_trailing_whitespace(text) if self.saving.final_newline: text += self._newline_string() return text.encode(self.saving.encoding)
[docs] def open_file(self, path): if not self.close_file(): return False path = os.path.abspath(path) self.path = path try: data = io.read_bytes_from_file(path) except OSError: LOGGER.error('cannot read file %r', path, exc_info=True) return False self.file_about_to_be_opened.emit(path) text = self._read_text(data) self.setText(text) self.setModified(False) self.file_opened.emit(path) return True
[docs] def open_document(self, other): if not self.close_file(): return False self.path = other.path self.setDocument(other.document()) self.modificationChanged.emit(self.isModified()) return True
[docs] @Slot() def reload_file(self): """Reload file contents (losing unsaved modifications) Reload file from disk and replace editor contents with updated text. If the user made modifications to the editor contents without saving them, calling this method will will lose them. However, the replacement can be undone by the user. """ old_pos = self.cursor_position() try: data = io.read_bytes_from_file(self.path) except OSError: LOGGER.error('cannot reload file %r', self.path, exc_info=True) return False text = self._read_text(data) with self.undo_group(): # XXX setText would clear the history self.selectAll() self.replaceSelectedText(text) self.setModified(False) self.setCursorPosition(*old_pos) return True
## various props
[docs] def set_use_final_newline(self, b): """Set whether a final newline should always be added when saving to disk If `b` is False, the contents of the editor won't be changed when saving file to disk: the file will only contain a final newline if the editor text ends with a newline. If `b` is True, a final newline will be added to the file saved on disk, but this final newline won't be shown in the editor. When the file is loaded, if it ends with a final newline, it won't be shown in the editor either, though will be kept when saving again. This does not cause the file to be re-saved. """ self.saving.final_newline = b
[docs] def use_final_newline(self): """Return True if always adding a final newline when saving. See :any:`set_use_final_newline`. """ return self.saving.final_newline
[docs] def set_remove_trailing_whitespace(self, b): """Set whether trailing whitespace should be trimmed when saving to disk If `b` is True, trailing whitespace will be removed from each line on the the file saved to disk. It is still kept in the editor though (but this behavior may change in the future). This does not cause the file to be re-saved. """ self.saving.trim_whitespace = b
[docs] def does_remove_trailing_whitespace(self): """Return True if always trimming trailing whitespace when saving. See :any:`set_remove_trailing_whitespace`. """ return self.saving.trim_whitespace
[docs] def set_encoding(self, s): """Set the file data encoding for loading/saving When loading file contents from disk or saving file to disk, this encoding will be used. This does not change the internal encoding used by the editor widget, which is utf-8. This does not cause the file to be re-saved. """ ''.encode(s) # ensure it's usable self.saving.encoding = s
[docs] def encoding(self): """Return the encoding to use for loading/saving""" return self.saving.encoding
## misc
[docs] @contextlib.contextmanager def undo_group(self, undo_on_error=False): """Context-manager to run actions in an undo-group. Operations done in this context manager are put in an undo-group: :any:`undo` and :any:`redo` will do them all-at-once. The undo-group is opened at the beginning of the context and automatically closed at the end of the context. For example, removing a whole word will appear the same, undo-wise, as removing the word character-by-character, if all characters are removed while an undo-group was open. :param undo_on_error: if an exception is raised inside the context, operations done in the group are undone :type undo_on_error: bool """ self.beginUndoAction() try: yield except Exception: self.endUndoAction() if undo_on_error: self.undo() raise self.endUndoAction()
[docs] @Slot() def goto1(self, line, col=None): col = col or 1 line, col = line - 1, col - 1 self.ensureLineVisible(line) self.setCursorPosition(line, col)
[docs] def cursor_line(self): """Return the line number of the cursor position (starting from 0)""" return self.cursor_position()[0]
[docs] def cursor_column(self): """Return the column number of the cursor position (starting from 0) Note the column number is the number of Unicode codepoints since the start of the line. For example, a tab character will count for 1 column only, see :any:`cursor_visual_column`. """ return self.getCursorPosition()[1]
[docs] def cursor_visual_column(self): lineno, colno = self.getCursorPosition() line = self.text(lineno)[:colno] line = line.expandtabs(self.tabWidth()) # warning: scintilla seems to have bugs when using decomposed unicode return iterlen(c for c in line if not unicodedata.combining(c))
[docs] def setLexer(self, lexer): QsciScintilla.setLexer(self, lexer) self._lexer = lexer self.lexer_changed.emit(lexer)
[docs] def lexer(self): lexer = QsciScintilla.lexer(self) if lexer is None: lexer = self._lexer return lexer
[docs] def cursor_position(self): """Return the cursor line-index starting from 0 .. note:: This function is misnamed in QsciScintilla and the naming is kept here to avoid more confusion. See :ref:`positions`. """ return self.getCursorPosition()
[docs] def cursor_line_index(self): """Return the cursor line-index starting from 0 See :ref:`positions`. """ return self.getCursorPosition()
[docs] def cursor_offset(self): """Return the cursor position in byte offset As this function returns a byte-offset, it should not be used unless necessary. See :ref:`positions`. """ return self.positionFromLineIndex(*self.getCursorPosition())
[docs] def bytes_length(self): """Return the length of the text in bytes""" return self.length()
[docs] def text_length(self): """Return the length of the text in Unicode codepoints""" return len(self.text())
## search @classmethod def _smart_case(cls, txt, cs): if cs is cls.SmartCaseSensitive: return (txt.lower() != txt) else: return cs def _search_options_to_re(self): expr = self.search.expr if self.search.is_re else re.escape(self.search.expr) if self.search.whole: expr = '\b%s\b' % expr case_sensitive = self._smart_case(expr, self.search.case_sensitive) flags = 0 if case_sensitive else re.I return re.compile(expr, flags) def _highlight_search(self): txt = self.text() reobj = self._search_options_to_re() for mtc in reobj.finditer(txt): self.indicators['search_highlight'].put_at_offset(mtc.start(), mtc.end())
[docs] def clear_search_highlight(self): self.indicators['search_highlight'].remove_at_offset(0, self.bytes_length())
[docs] def find(self, expr, case_sensitive=None, is_re=None, whole=None, wrap=None): if self.search.highlight: self.clear_search_highlight() self.search.expr = expr if case_sensitive is not None: self.search.case_sensitive = case_sensitive if is_re is not None: self.search.is_re = is_re if whole is not None: self.search.whole = whole if wrap is not None: self.search.wrap = wrap self.search.forward = True case_sensitive = self._smart_case(expr, self.search.case_sensitive) if self.search.highlight: self._highlight_search() lfrom, ifrom, lto, ito = self.getSelection() self.setCursorPosition(*min([(lfrom, ifrom), (lto, ito)])) return self.findFirst(self.search.expr, self.search.is_re, case_sensitive, self.search.whole, self.search.wrap, True)
def _find_in_direction(self, forward): if self.search.get('forward') == forward: return self.findNext() else: self.search.forward = forward case_sensitive = self._smart_case(self.search.expr, self.search.case_sensitive) b = self.findFirst(self.search.expr, self.search.is_re, case_sensitive, self.search.whole, self.search.wrap, self.search.forward) if b and not forward: # weird behavior when switching from forward to backward return self.findNext() return b
[docs] def find_forward(self): return self._find_in_direction(True)
[docs] def find_backward(self): return self._find_in_direction(False)
[docs] def word_at_cursor(self): return self.wordAtLineIndex(*self.getCursorPosition())
[docs] def word_at_pos(self, pos): return self.word_at_line_index(*self.line_index_from_position(pos))
## annotations
[docs] def annotate_append(self, line, item, style=None): """Append a new annotation Add an annotation for `line`. If there was an existing annotation at this line, unlike :any:`annotate`, the old annotation is not overwritten, but the new annotation is appended to the old one. If `item` is a string, it should be the text of the annotation to add, and `style` argument must be given. `item` can be a `QsciStyledText` object, which comprises both the text and the style, so the `style` argument should not be passed. :param line: the line of the editor where to add the annotation :type line: int """ annotations = self.annotation_styled_text(line) if isinstance(item, bytes): item = [QsciStyledText(item.decode('utf-8'), style)] elif isinstance(item, str): item = [QsciStyledText(item, style)] elif isinstance(item, QsciStyledText): assert style is None item = [item] self.annotate(line, annotations + item)
[docs] def annotate_append_line(self, line, item, style=None): """Append a new annotation on a line """ current = self.annotation(line) if len(current) and not current.endswith('\n'): self.annotate_append(line, '\n', 0) return self.annotate_append(line, item, style)
## signals file_about_to_be_saved = Signal(str) """Signal file_about_to_be_saved(str)""" file_saved = Signal(str) """Signal file_saved(str)""" file_saved_as = Signal(str) """Signal file_saved_as(str)""" file_about_to_be_opened = Signal(str) """Signal file_about_to_be_opened(str)""" file_opened = Signal(str) """Signal file_opened(str)""" lexer_changed = Signal(object) """Signal lexer_changed(object)""" file_modified_externally = Signal() """Signal file_modified_externally()""" position_jumped = Signal(int, int) """Signal position_jumped(int, int)""" ## events
[docs] @override def closeEvent(self, ev): accept_if(ev, self.close_file())
def iterlen(iterable): return sum(1 for _ in iterable)
[docs] @register_event_filter('editor', [QEvent.Wheel]) @disabled def zoom_on_wheel(ed, ev): if ev.modifiers() == Qt.ControlModifier: delta = ev.angleDelta() if delta.y() > 0: ed.zoomIn() return True elif delta.y() < 0: ed.zoomOut() return True return False