Source code for teek._widgets.text

import collections.abc
import functools
import re

import teek
from teek._structures import CgetConfigureConfigDict
from teek._tcl_calls import make_thread_safe
from teek._widgets.base import BindingDict, ChildMixin, Widget


# a new subclass of IndexBase is created for each text widget, and inheriting
# from namedtuple makes comparing the text indexes work nicely
class IndexBase(collections.namedtuple('TextIndex', 'line column')):
    _widget = None

    def to_tcl(self):
        return '%d.%d' % self       # lol, magicz

    @classmethod
    @make_thread_safe
    def from_tcl(cls, string):
        # the returned index may be out of bounds ONLY IF it doesn't contain
        # anything more fancy than 'line.column'
        if re.fullmatch(r'(\d+)\.(\d+)', string) is None:
            string = cls._widget._call(str, cls._widget, 'index', string)

            # hide the invisible newline that tk wants to have at the end
            tk_fake_end = cls._widget._call(str, cls._widget, 'index', 'end')
            if string == tk_fake_end:
                return cls._widget.end

        return cls(*map(int, string.split('.')))

    @make_thread_safe
    def between_start_end(self):
        if self < self._widget.start:
            return self._widget.start
        if self > self._widget.end:
            return self._widget.end
        return self

    def forward(self, *, chars=0, indices=0, lines=0):
        result = '%s %+d lines %+d chars %+d indices' % (
            self.to_tcl(), lines, chars, indices)
        return type(self).from_tcl(result).between_start_end()

    def back(self, *, chars=0, indices=0, lines=0):
        return self.forward(chars=-chars, indices=-indices, lines=-lines)

    def _apply_suffix(self, suffix):
        return type(self).from_tcl(
            '%s %s' % (self.to_tcl(), suffix)).between_start_end()

    linestart = functools.partialmethod(_apply_suffix, 'linestart')
    lineend = functools.partialmethod(_apply_suffix, 'lineend')
    wordstart = functools.partialmethod(_apply_suffix, 'wordstart')
    wordend = functools.partialmethod(_apply_suffix, 'wordend')


class Tag(CgetConfigureConfigDict):

    def __init__(self, widget, name):
        self._widget = widget
        self.name = name
        super().__init__(self._call_tag_subcommand)

        self._types.update({
            'background': teek.Color,
            #'bgstipple': ???,
            'borderwidth': teek.ScreenDistance,
            'elide': bool,
            #'fgstipple': ???,
            'font': teek.Font,
            'foreground': teek.Color,
            'justify': str,
            'lmargin1': teek.ScreenDistance,
            'lmargin2': teek.ScreenDistance,
            'lmargin3': teek.ScreenDistance,
            'lmargincolor': teek.Color,
            'offset': teek.ScreenDistance,
            'overstrike': bool,
            'overstrikefg': teek.Color,
            'relief': str,
            'rmargin': teek.ScreenDistance,
            'rmargincolor': teek.Color,
            'selectbackground': teek.Color,
            'selectforeground': teek.Color,
            'spacing1': teek.ScreenDistance,
            'spacing2': teek.ScreenDistance,
            'spacing3': teek.ScreenDistance,
            'tabs': [str],
            'tabstyle': str,
            'underline': bool,
            'underlinefg': teek.Color,
            'wrap': str,
        })

        self.bindings = BindingDict(self._call_bind, widget.command_list)
        self.bind = self.bindings._convenience_bind

    def __repr__(self):
        return '<Text widget tag %r>' % self.name

    def to_tcl(self):
        return self.name

    # this inserts self between the arguments, that's why it's needed
    def _call_tag_subcommand(self, returntype, subcommand, *args):
        return self._widget._call(
            returntype, self._widget, 'tag', subcommand, self, *args)

    def _call_bind(self, returntype, *args):
        return self._call_tag_subcommand(returntype, 'bind', *args)

    def __eq__(self, other):
        if not isinstance(other, Tag):
            return NotImplemented
        return self._widget is other._widget and self.name == other.name

    def __hash__(self):
        return hash(self.name)

    @make_thread_safe
    def add(self, index1, index2):
        index1 = self._widget._get_index_obj(index1)
        index2 = self._widget._get_index_obj(index2)
        return self._call_tag_subcommand(None, 'add', index1, index2)

    # TODO: bind

    def delete(self):
        self._call_tag_subcommand(None, 'delete')

    @make_thread_safe
    def _prevrange_or_nextrange(self, prev_or_next, index1, index2=None):
        index1 = self._widget._get_index_obj(index1)
        if index2 is None:
            index2 = {'prev': self._widget.start,
                      'next': self._widget.end}[prev_or_next]
        else:
            index2 = self._widget._get_index_obj(index2)

        results = self._call_tag_subcommand(
            [self._widget.TextIndex], prev_or_next + 'range', index1, index2)
        if not results:
            # the tcl command returned "", no ranges found
            return None

        index1, index2 = results
        return (index1.between_start_end(), index2.between_start_end())

    prevrange = functools.partialmethod(_prevrange_or_nextrange, 'prev')
    nextrange = functools.partialmethod(_prevrange_or_nextrange, 'next')

    @make_thread_safe
    def ranges(self):
        flat_pairs = map(self._widget.TextIndex.from_tcl,
                         self._call_tag_subcommand([str], 'ranges'))

        # magic to convert a flat iterator to pairs: a,b,c,d --> (a,b), (c,d)
        return list(zip(flat_pairs, flat_pairs))

    @make_thread_safe
    def remove(self, index1=None, index2=None):
        if index1 is None:
            index1 = self._widget.start
        else:
            index1 = self._widget._get_index_obj(index1)

        if index2 is None:
            index2 = self._widget.end
        else:
            index2 = self._widget._get_index_obj(index2)

        self._call_tag_subcommand(None, 'remove', index1, index2)

    @make_thread_safe
    def _lower_or_raise(self, lower_or_raise, other_tag=None):
        if other_tag is None:
            self._call_tag_subcommand(None, lower_or_raise)
        else:
            self._call_tag_subcommand(None, lower_or_raise, other_tag)

    lower = functools.partialmethod(_lower_or_raise, 'lower')
    raise_ = functools.partialmethod(_lower_or_raise, 'raise')


class MarksDict(collections.abc.MutableMapping):

    def __init__(self, widget):
        self._widget = widget

    def _get_name_list(self):
        return self._widget._call([str], self._widget, 'mark', 'names')

    def __iter__(self):
        return iter(self._get_name_list())

    def __len__(self):
        return len(self._get_name_list())

    @make_thread_safe
    def __setitem__(self, name, index):
        index = self._widget._get_index_obj(index)
        self._widget._call(None, self._widget, 'mark', 'set', name, index)

    @make_thread_safe
    def __getitem__(self, name):
        if name not in self._get_name_list():
            raise KeyError(name)

        result = self._widget._call(
            self._widget.TextIndex, self._widget, 'index', name)
        return result.between_start_end()

    def __delitem__(self, name):
        self._widget._call(None, self._widget, 'mark', 'unset', name)


[docs]class Text(ChildMixin, Widget): r"""This is the text widget. Manual page: :man:`text(3tk)` .. attribute:: start end :ref:`TextIndex objects <textwidget-index>` that represents the start and end of the text. .. tip:: Use ``textwidget.end.line`` to count the number of lines of text in the text widget. Note that ``end`` changes when the text widget's content changes: >>> window = teek.Window() >>> text = teek.Text(window) >>> text.end TextIndex(line=1, column=0) >>> old_end = text.end >>> text.insert(text.end, 'hello') >>> text.end TextIndex(line=1, column=5) >>> old_end TextIndex(line=1, column=0) >>> text.get(old_end, text.end) 'hello' Tk has a concept of an invisible newline character at the end of the widget. In pure Tcl or in tkinter, getting the text from beginning to ``end`` returns the text in the widget plus a ``\n``, which is why you almost always need to do ``end - 1 char`` instead of just ``end``. **Teek doesn't do that** because 99% of the time it's not useful and 1% of the time it's confusing to people reading the code, so ``text.get(text.start, text.end)`` doesn't return anything that is not visible in the text widget. .. attribute:: marks A dictionary-like object with mark names as keys and :ref:`index objects <textwidget-index>` as values. See :ref:`textwidget-marks`. .. method:: xview(*args) yview(*args) These call ``pathName xview`` and ``pathName yview`` as documented in :man:`text(3tk)`. Pass string arguments to these methods to invoke the subcommands. For example, ``text_widget.yview('moveto', 1)`` scrolls to end vertically. If no arguments are given, these methods return a two-tuple of floats (see the manual page); otherwise, None is returned. """ _widget_name = 'text' tk_class_name = 'Text' def __init__(self, parent, **kwargs): super().__init__(parent, **kwargs) self.TextIndex = type( # creates a new subclass of IndexBase 'TextIndex', (IndexBase,), {'_widget': self}) self._tag_objects = {} self.marks = MarksDict(self) def _init_config(self): super()._init_config() self.config._types.update({ 'autoseparators': bool, 'blockcursor': bool, 'endline': int, # undocumented: height can also be a screen distance?? # probably a bug 'height': int, 'inactiveselectbackground': teek.Color, 'insertunfocussed': str, 'maxundo': int, 'spacing1': teek.ScreenDistance, 'spacing2': teek.ScreenDistance, 'spacing3': teek.ScreenDistance, 'startline': int, 'state': str, 'tabs': [str], 'tabstyle': str, 'undo': bool, 'width': int, 'wrap': str, }) def _repr_parts(self): return ['contains %d lines of text' % self.end.line] def _get_index_obj(self, index): if isinstance(index, str): raise TypeError( "string indexes are not supported, use (line, column) int " "tuples or TextIndex objects instead") return self.TextIndex(*index).between_start_end()
[docs] @make_thread_safe def get_tag(self, name): """Return a tag object by name, creating a new one if needed.""" try: return self._tag_objects[name] except KeyError: tag = Tag(self, name) # this actually creates the tag so that it shows up in # get_all_tags() self._call(None, self, 'tag', 'configure', name) self._tag_objects[name] = tag return tag
[docs] @make_thread_safe def get_all_tags(self, index=None): """Return all tags as tag objects. See ``pathName tag names`` in :man:`text(3tk)` for more details. """ args = [self, 'tag', 'names'] if index is not None: args.append(self._get_index_obj(index)) return [self.get_tag(name) for name in self._call([str], *args)]
@property def start(self): return self.TextIndex(1, 0) @property def end(self): index_string = self._call(str, self, 'index', 'end - 1 char') return self.TextIndex(*map(int, index_string.split('.')))
[docs] @make_thread_safe def get(self, index1=None, index2=None): """Return text in the widget. If the indexes are not given, they default to the beginning and end of the text widget, respectively. """ if index1 is None: index1 = self.start else: index1 = self._get_index_obj(index1) if index2 is None: index2 = self.end else: index2 = self._get_index_obj(index2) return self._call(str, self, 'get', index1, index2)
[docs] @make_thread_safe def insert(self, index, text, tag_list=()): """Add text to the widget. The ``tag_list`` can be any iterable of tag name strings or tag objects. """ index = self._get_index_obj(index) self._call(None, self, 'insert', index, text, tag_list)
[docs] @make_thread_safe def replace(self, index1, index2, new_text, tag_list=()): """See :man:`text(3tk)` and :meth:`insert`.""" self._call(None, self, 'replace', self._get_index_obj(index1), self._get_index_obj(index2), new_text, tag_list)
[docs] def delete(self, index1, index2): """See :man:`text(3tk)` and :meth:`insert`.""" self._call(None, self, 'delete', self._get_index_obj(index1), self._get_index_obj(index2))
[docs] def see(self, index): """Scroll so that an index is visible. See :man:`text(3tk)` for details. """ self._call(None, self, 'see', self._get_index_obj(index))
def _xview_or_yview(self, xview_or_yview, *args): if not args: return self._call((float, float), self, xview_or_yview) self._call(None, self, xview_or_yview, *args) return None xview = functools.partialmethod(_xview_or_yview, 'xview') yview = functools.partialmethod(_xview_or_yview, 'yview')