From b748cc4f7e9ce37c7b60632947ac3c8bea6dd916 Mon Sep 17 00:00:00 2001 From: Eugenio Parodi Date: Thu, 24 Feb 2022 17:18:39 +0000 Subject: [PATCH] Added mouse selection to the text edit --- README.md | 3 +- TermTk/TTkCore/string.py | 13 +++- TermTk/TTkWidgets/texedit.py | 144 ++++++++++++++++++++++++++++++----- demo/showcase/textedit.py | 62 +++++++++++++++ 4 files changed, 200 insertions(+), 22 deletions(-) create mode 100755 demo/showcase/textedit.py diff --git a/README.md b/README.md index b942c325..5285f7b1 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,9 @@ cprofilev -f profiler.txt - [Textual](https://github.com/Textualize/textual) - TUI (Text User Interface) framework for Python inspired by modern web development - [Rich](https://github.com/Textualize/rich) - Python library for rich text and beautiful formatting in the terminal - [PyCuT](https://github.com/ceccopierangiolieugenio/pyCuT) - terminal graphic library loosely based on QT api (my previous failed attempt) + - [pyTooling.TerminalUI](https://github.com/pyTooling/pyTooling.TerminalUI) - A set of helpers to implement a text user interface (TUI) in a terminal. - Non Python - [Turbo Vision](http://tvision.sourceforge.net) - [ncurses](https://en.wikipedia.org/wiki/Ncurses) - - [tui.el](https://github.com/ebpa/tui.el) - An experimental text-based UI framework for Emacs modeled after React \ No newline at end of file + - [tui.el](https://github.com/ebpa/tui.el) - An experimental text-based UI framework for Emacs modeled after React diff --git a/TermTk/TTkCore/string.py b/TermTk/TTkCore/string.py index 7010092c..692855d2 100644 --- a/TermTk/TTkCore/string.py +++ b/TermTk/TTkCore/string.py @@ -160,10 +160,12 @@ class TTkString(): return ret - def setColor(self, color, match=None, posFrom=0, posTo=0): + def setColor(self, color, match=None, posFrom=None, posTo=None): ret = TTkString() ret._text += self._text - if match: + if posFrom == posTo == None: + ret._colors = [color]*len(self._text) + elif match: ret._colors += self._colors start=0 lenMatch = len(match) @@ -176,7 +178,7 @@ class TTkString(): for i in range(posFrom, posTo): ret._colors[i] = color else: - ret._colors = [color]*len(self._text) + ret._colors += self._colors return ret def substring(self, fr=None, to=None): @@ -206,4 +208,7 @@ class TTkString(): return re.search(regexp, self._text, re.IGNORECASE if ignoreCase else 0) def findall(self, regexp, ignoreCase=False): - return re.findall(regexp, self._text, re.IGNORECASE if ignoreCase else 0) \ No newline at end of file + return re.findall(regexp, self._text, re.IGNORECASE if ignoreCase else 0) + + def getIndexes(self, char): + return [i for i,c in enumerate(self._text) if c==char] \ No newline at end of file diff --git a/TermTk/TTkWidgets/texedit.py b/TermTk/TTkWidgets/texedit.py index 15aef22d..dea426a0 100644 --- a/TermTk/TTkWidgets/texedit.py +++ b/TermTk/TTkWidgets/texedit.py @@ -33,34 +33,49 @@ from TermTk.TTkAbstract.abstractscrollarea import TTkAbstractScrollArea from TermTk.TTkAbstract.abstractscrollview import TTkAbstractScrollView +''' + Design: + +''' class _TTkTextEditView(TTkAbstractScrollView): - __slots__ = ('_lines', '_hsize') + __slots__ = ( + '_lines', '_hsize', + '_cursorPos', '_cursorParams', '_selectionFrom', '_selectionTo', + '_replace', + '_readOnly' + ) def __init__(self, *args, **kwargs): super().__init__(self, *args, **kwargs) self._name = kwargs.get('name' , '_TTkTextEditView' ) + self._readOnly = True self._hsize = 0 - self._lines = [] + self._lines = [''] + self._replace = False + self._cursorPos = (0,0) + self._selectionFrom = (0,0) + self._selectionTo = (0,0) + self._cursorParams = None + self.setFocusPolicy(TTkK.ClickFocus + TTkK.TabFocus) - @pyTTkSlot(str) - def setText(self, text): - self._lines = [line for line in text.split('\n')] - self.viewMoveTo(0, 0) - self._updateSize() - self.viewChanged.emit() - self.update() + + def isReadOnly(self) -> bool : + return self._readOnly + + def setReadOnly(self, ro): + self._readOnly = ro @pyTTkSlot(str) - def setLines(self, lines): - self._lines = lines + def setText(self, text): + if type(text) == str: + text = TTkString() + text + self._lines = text.split('\n') self.viewMoveTo(0, 0) self._updateSize() self.viewChanged.emit() self.update() def _updateSize(self): - self._hsize = 0 - for l in self._lines: - self._hsize = max(self._hsize, len(l)) + self._hsize = max( [ len(l) for l in self._lines ] ) def viewFullAreaSize(self) -> (int, int): return self._hsize, len(self._lines) @@ -68,17 +83,112 @@ class _TTkTextEditView(TTkAbstractScrollView): def viewDisplayedSize(self) -> (int, int): return self.size() + def _pushCursor(self): + if self._readOnly or not self.hasFocus(): + return + ox, oy = self.getViewOffsets() + + x = self._cursorPos[0]-ox + y = self._cursorPos[1]-oy + + if x >= self.width() or y>=self.height() or \ + self._selectionFrom != self._selectionTo or \ + x<0 or y<0: + TTkHelper.hideCursor() + return + + # Avoid the show/move cursor to be called again if in the same position + if self._cursorParams and \ + self._cursorParams['pos'] == (x,y) and \ + self._cursorParams['replace'] == self._replace: + return + + self._cursorParams = {'pos': (x,y), 'replace': self._replace} + TTkHelper.moveCursor(self,x,y) + if self._replace: + TTkHelper.showCursor(TTkK.Cursor_Blinking_Block) + else: + TTkHelper.showCursor(TTkK.Cursor_Blinking_Bar) + self.update() + + def mousePressEvent(self, evt) -> bool: + if self._readOnly: + return super().mousePressEvent(evt) + ox, oy = self.getViewOffsets() + y = max(0,min(evt.y + oy,len(self._lines))) + x = max(0,min(evt.x + ox,len(self._lines[y]))) + self._cursorPos = (x,y) + self._selectionFrom = (x,y) + self._selectionTo = (x,y) + # TTkLog.debug(f"{self._cursorPos=}") + self.update() + return True + + def mouseDragEvent(self, evt) -> bool: + if self._readOnly: + return super().mouseDragEvent(evt) + ox, oy = self.getViewOffsets() + y = max(0,min(evt.y + oy,len(self._lines))) + x = max(0,min(evt.x + ox,len(self._lines[y]))) + + self._selectionFrom = ( min(x,self._cursorPos[0]), min(y,self._cursorPos[1]) ) + self._selectionTo = ( max(x,self._cursorPos[0]), max(y,self._cursorPos[1]) ) + + self.update() + return True + + def mouseDoubleClickEvent(self, evt) -> bool: + if self._readOnly: + return super().mouseDoubleClickEvent(evt) + return True + + def mouseTapEvent(self, evt) -> bool: + if self._readOnly: + return super().mouseTapEvent(evt) + return True + + def keyEvent(self, evt): + if self._readOnly: + return super().keyEvent(evt) + return True + + def focusInEvent(self): + self._pushCursor() + self.update() + + def focusOutEvent(self): + TTkHelper.hideCursor() + def paintEvent(self): ox, oy = self.getViewOffsets() - for y, t in enumerate(self._lines[oy:]): + if self.hasFocus(): + color = TTkCfg.theme.lineEditTextColorFocus + selectColor = TTkCfg.theme.lineEditTextColorSelected + else: + color = TTkCfg.theme.lineEditTextColor + selectColor = TTkCfg.theme.lineEditTextColorSelected + + h = self.height() + for y, t in enumerate(self._lines[oy:oy+h]): + if self._selectionFrom[1] <= y+oy <= self._selectionTo[1]: + pf = 0 if y+oy > self._selectionFrom[1] else self._selectionFrom[0] + pt = len(t) if y+oy < self._selectionTo[1] else self._selectionTo[0] + t = t.setColor(color=selectColor, posFrom=pf, posTo=pt ) self._canvas.drawText(pos=(-ox,y), text=t) + self._pushCursor() class TTkTextEdit(TTkAbstractScrollArea): - __slots__ = ('_textEditView', 'setText', 'setColoredLines') + __slots__ = ( + '_textEditView', + # Forwarded Methods + 'setText', 'isReadOnly', 'setReadOnly' + ) def __init__(self, *args, **kwargs): super().__init__(self, *args, **kwargs) self._name = kwargs.get('name' , 'TTkTextEdit' ) self._textEditView = _TTkTextEditView() self.setViewport(self._textEditView) self.setText = self._textEditView.setText - self.setLines = self._textEditView.setLines + self.isReadOnly = self._textEditView.isReadOnly + self.setReadOnly = self._textEditView.setReadOnly + diff --git a/demo/showcase/textedit.py b/demo/showcase/textedit.py new file mode 100755 index 00000000..e43858d3 --- /dev/null +++ b/demo/showcase/textedit.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +# MIT License +# +# Copyright (c) 2021 Eugenio Parodi +# +# 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. + +import os +import sys +import random +import argparse + +sys.path.append(os.path.join(sys.path[0],'../..')) +import TermTk as ttk + +words = ["Lorem", "ipsum", "dolor", "sit", "amet,", "consectetur", "adipiscing", "elit,", "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore", "magna", "aliqua.", "Ut", "enim", "ad", "minim", "veniam,", "quis", "nostrud", "exercitation", "ullamco", "laboris", "nisi", "ut", "aliquip", "ex", "ea", "commodo", "consequat.", "Duis", "aute", "irure", "dolor", "in", "reprehenderit", "in", "voluptate", "velit", "esse", "cillum", "dolore", "eu", "fugiat", "nulla", "pariatur.", "Excepteur", "sint", "occaecat", "cupidatat", "non", "proident,", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollit", "anim", "id", "est", "laborum."] +def getWord(): + return random.choice(words) +def getSentence(a,b): + return " ".join([getWord() for i in range(0,random.randint(a,b))]) + +def demoTextEdit(root=None): + te = ttk.TTkTextEdit(parent=root) + te.setReadOnly(False) + te.setText('\n'.join([ getSentence(10,20) for _ in range(50)])) + return te + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('-f', help='Full Screen', action='store_true') + args = parser.parse_args() + + ttk.TTkLog.use_default_file_logging() + + root = ttk.TTk() + if args.f: + rootTree1 = root + root.setLayout(ttk.TTkGridLayout()) + else: + rootTree1 = ttk.TTkWindow(parent=root,pos = (0,0), size=(70,40), title="Test Text Edit", layout=ttk.TTkGridLayout(), border=True) + demoTextEdit(rootTree1) + root.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file