diff --git a/TermTk/TTkCore/TTkTerm/input.py b/TermTk/TTkCore/TTkTerm/input.py index d32748bb..848ed2c9 100644 --- a/TermTk/TTkCore/TTkTerm/input.py +++ b/TermTk/TTkCore/TTkTerm/input.py @@ -46,12 +46,16 @@ class TTkInput: '_readInput', '_leftLastTime', '_midLastTime', '_rightLastTime', '_leftTap', '_midTap', '_rightTap', + '_pasteBuffer', '_bracketedPaste', # Signals - 'inputEvent' + 'inputEvent', 'pasteEvent' ) def __init__(self): self.inputEvent = pyTTkSignal(TTkKeyEvent, TTkMouseEvent) + self.pasteEvent = pyTTkSignal(str) + self._pasteBuffer = "" + self._bracketedPaste = False self._readInput = None self._leftLastTime = 0 self._midLastTime = 0 @@ -79,7 +83,20 @@ class TTkInput: mouse_re = re.compile(r"\033\[<(\d+);(\d+);(\d+)([mM])") def key_process(self, stdinRead): + if self._bracketedPaste: + if stdinRead.endswith("\033[201~"): + self._pasteBuffer += stdinRead[:-6] + self._bracketedPaste = False + # due to the CRNL methos (don't ask me why) the terminal + # is substituting all the \n with \r + self.pasteEvent.emit(self._pasteBuffer.replace('\r','\n')) + self._pasteBuffer = "" + else: + self._pasteBuffer += stdinRead + return + mevt,kevt = None, None + if not stdinRead.startswith("\033[<"): # Key Event kevt = TTkKeyEvent.parse(stdinRead) @@ -153,12 +170,17 @@ class TTkInput: evt = TTkMouseEvent.Move mevt = TTkMouseEvent(x, y, key, evt, mod, tap, m.group(0).replace("\033", "")) + if kevt or mevt: + self.inputEvent.emit(kevt, mevt) + return - if kevt is None and mevt is None: - hex = [f"0x{ord(x):02x}" for x in stdinRead] - TTkLog.error("UNHANDLED: "+stdinRead.replace("\033","") + " - "+",".join(hex)) + if stdinRead.startswith("\033[200~"): + self._pasteBuffer = stdinRead[6:] + self._bracketedPaste = True + return - self.inputEvent.emit(kevt, mevt) + hex = [f"0x{ord(x):02x}" for x in stdinRead] + TTkLog.error("UNHANDLED: "+stdinRead.replace("\033","") + " - "+",".join(hex)) def main(): diff --git a/TermTk/TTkCore/TTkTerm/term_base.py b/TermTk/TTkCore/TTkTerm/term_base.py index b6f7e2c6..b26210db 100644 --- a/TermTk/TTkCore/TTkTerm/term_base.py +++ b/TermTk/TTkCore/TTkTerm/term_base.py @@ -29,6 +29,9 @@ class TTkTermBase(): ALT_SCREEN = "\033[?1049h" #* Switch to alternate screen NORMAL_SCREEN = "\033[?1049l" #* Switch to normal screen + SET_BRACKETED_PM = "\033[?2004h" # Ps = 2 0 0 4 ⇒ Set bracketed paste mode, xterm. + RESET_BRACKETED_PM = "\033[?2004l" # Ps = 2 0 0 4 ⇒ Reset bracketed paste mode, xterm. + class Mouse(): ON = "\033[?1002h\033[?1006h" # Enable reporting of mouse position on click and release OFF = "\033[?1002l\033[?1006l" # Disable mouse reporting @@ -95,7 +98,10 @@ class TTkTermBase(): TTkTermBase.mouse = mouse | directMouse TTkTermBase.directMouse = directMouse TTkTermBase.Cursor.hide() - TTkTermBase.push(TTkTermBase.ALT_SCREEN + TTkTermBase.CLEAR + TTkTermBase.Cursor.HIDE + TTkTermBase.escTitle(TTkTermBase.title)) + TTkTermBase.push(TTkTermBase.escTitle(TTkTermBase.title)) + TTkTermBase.push(TTkTermBase.ALT_SCREEN) + TTkTermBase.push(TTkTermBase.SET_BRACKETED_PM) + TTkTermBase.push(TTkTermBase.CLEAR + TTkTermBase.Cursor.HIDE) if TTkTermBase.mouse: TTkTermBase.push(TTkTermBase.Mouse.ON) if TTkTermBase.directMouse: @@ -107,20 +113,20 @@ class TTkTermBase(): @staticmethod def exit(): TTkTermBase.push(TTkTermBase.Mouse.OFF + TTkTermBase.Mouse.DIRECT_OFF) - TTkTermBase.push(TTkTermBase.CLEAR + TTkTermBase.NORMAL_SCREEN + TTkTermBase.Cursor.SHOW + TTkTermBase.escTitle()) + TTkTermBase.push(TTkTermBase.CLEAR + TTkTermBase.NORMAL_SCREEN + TTkTermBase.RESET_BRACKETED_PM + TTkTermBase.Cursor.SHOW + TTkTermBase.escTitle()) TTkTermBase.setEcho(True) TTkTermBase.CRNL(True) @staticmethod def stop(): TTkTermBase.push(TTkTermBase.Mouse.OFF + TTkTermBase.Mouse.DIRECT_OFF) - TTkTermBase.push(TTkTermBase.CLEAR + TTkTermBase.NORMAL_SCREEN + TTkTermBase.Cursor.SHOW + TTkTermBase.escTitle()) + TTkTermBase.push(TTkTermBase.CLEAR + TTkTermBase.NORMAL_SCREEN + TTkTermBase.RESET_BRACKETED_PM + TTkTermBase.Cursor.SHOW + TTkTermBase.escTitle()) TTkTermBase.setEcho(True) TTkTermBase.CRNL(True) @staticmethod def cont(): - TTkTermBase.push(TTkTermBase.ALT_SCREEN + TTkTermBase.CLEAR + TTkTermBase.Cursor.HIDE + TTkTermBase.escTitle(TTkTermBase.title)) + TTkTermBase.push(TTkTermBase.ALT_SCREEN + TTkTermBase.SET_BRACKETED_PM + TTkTermBase.CLEAR + TTkTermBase.Cursor.HIDE + TTkTermBase.escTitle(TTkTermBase.title)) if TTkTermBase.mouse: TTkTermBase.push(TTkTermBase.Mouse.ON) if TTkTermBase.directMouse: diff --git a/TermTk/TTkCore/string.py b/TermTk/TTkCore/string.py index 86785ffe..c88a6c18 100644 --- a/TermTk/TTkCore/string.py +++ b/TermTk/TTkCore/string.py @@ -72,6 +72,16 @@ class TTkString(): self._checkWidth() # raise AttributeError(f"{type(text)} not supported in TTkString") + @staticmethod + def _importString1(text, colors): + ret = TTkString() + ret._text = text + ret._colors = colors + ret._baseColor = colors[-1] + ret._hasTab = '\t' in text + ret._checkWidth() + return ret + @staticmethod def _parseAnsi(text, color = TTkColor.RST): pos = 0 diff --git a/TermTk/TTkCore/ttk.py b/TermTk/TTkCore/ttk.py index c7de186a..caf2d8fc 100644 --- a/TermTk/TTkCore/ttk.py +++ b/TermTk/TTkCore/ttk.py @@ -100,6 +100,7 @@ class TTk(TTkWidget): self._termDirectMouse = kwargs.get('mouseTrack',False) self._input = TTkInput() self._input.inputEvent.connect(self._processInput) + self._input.pasteEvent.connect(self._processPaste) self._title = kwargs.get('title','TermTk') self._sigmask = kwargs.get('sigmask', TTkK.NONE) self._showMouseCursor = os.environ.get("TTK_MOUSE",kwargs.get('mouseCursor', False)) @@ -179,6 +180,12 @@ class TTk(TTkWidget): return self._input.start() + @pyTTkSlot(str) + def _processPaste(self, txt:str): + if focusWidget := TTkHelper.getFocus(): + while focusWidget and not focusWidget.pasteEvent(txt): + focusWidget = focusWidget.parentWidget() + @pyTTkSlot(TTkKeyEvent, TTkMouseEvent) def _processInput(self, kevt, mevt): self._drawMutex.acquire() diff --git a/TermTk/TTkGui/textcursor.py b/TermTk/TTkGui/textcursor.py index 141d6e22..1e008cf0 100644 --- a/TermTk/TTkGui/textcursor.py +++ b/TermTk/TTkGui/textcursor.py @@ -308,7 +308,7 @@ class TTkTextCursor(): l = self._document._dataLines[-1] self.setPosition(len(self._document._dataLines)-1, len(l), moveMode, cID=cID) - operations = { + op = { TTkTextCursor.Right : moveRight, TTkTextCursor.Left : moveLeft, TTkTextCursor.Up : moveUpDown(-1), @@ -316,18 +316,19 @@ class TTkTextCursor(): TTkTextCursor.EndOfLine : moveEndOfLine, TTkTextCursor.StartOfLine: moveHome, TTkTextCursor.End: moveEnd, - } + }.get(operation,lambda _:_) - for cID, prop in enumerate(self._properties): + for _ in range(n): + for cID, prop in enumerate(self._properties): p = prop.position - operations.get(operation,lambda _:_)(cID,p,n) + op(cID,p,n) self._checkCursors(notify=self.position().toNum()!=currPos) def document(self): return self._document - def replaceText(self, text): + def replaceText(self, text, moveCursor=False): # if there is no selection, just select the next n chars till the end of the line # the newline is not replaced for p in self._properties: @@ -340,9 +341,9 @@ class TTkTextCursor(): pos = self._document._dataLines[line].nextPos(pos) pos = min(size,pos) p.anchor.set(line,pos) - return self.insertText(text) + return self.insertText(text, moveCursor) - def insertText(self, text): + def insertText(self, text, moveCursor=False): _lineFirst = -1 if self.hasSelection(): _lineFirst, _lineRem, _lineAdd = self._removeSelectedText() @@ -395,6 +396,8 @@ class TTkTextCursor(): for nl in reversed(newLines[1:]): self._document._dataLines.insert(l+1, nl) + # Move/Shift the cursors based on the pasted content + # # 2 scenarios: # 1) No Newline(s) added # p p+1 p+2 @@ -417,7 +420,8 @@ class TTkTextCursor(): diffPos = len(text.split('\n')[-1]) - p else: diffPos = len(text) - for pp in self._properties[i+1:]: + # Realign all the cursos (move the same if required) + for pp in self._properties[i+(0 if moveCursor else 1):]: if pp.position.line == l: pp.position.pos += diffPos pp.anchor.pos += diffPos diff --git a/TermTk/TTkWidgets/TTkPickers/textpicker.py b/TermTk/TTkWidgets/TTkPickers/textpicker.py index 94a8117c..e5b6c7d4 100644 --- a/TermTk/TTkWidgets/TTkPickers/textpicker.py +++ b/TermTk/TTkWidgets/TTkPickers/textpicker.py @@ -165,8 +165,7 @@ class TTkTextDialogPicker(TTkWindow): def _showEmojiPicker(): ep = _emojiPicker(size=(40,10)) def _addEmoji(e): - self._textEdit.textCursor().insertText(e) - self._textEdit.textCursor().movePosition(TTkTextCursor.Right) + self._textEdit.textCursor().insertText(e, moveCursor=True) ep.emojiClicked.connect(_addEmoji) TTkHelper.overlay(btn_emoji, ep, 0, 0) diff --git a/TermTk/TTkWidgets/lineedit.py b/TermTk/TTkWidgets/lineedit.py index a46429ed..050c36d1 100644 --- a/TermTk/TTkWidgets/lineedit.py +++ b/TermTk/TTkWidgets/lineedit.py @@ -177,6 +177,32 @@ class TTkLineEdit(TTkWidget): self.update() return True + def pasteEvent(self, txt:str): + txt = TTkString().join(txt.split('\n')) + + text = self._text + + if self._selectionFrom < self._selectionTo: + pre = text.substring(to=self._selectionFrom) + post = text.substring(fr=self._selectionTo) + self._cursorPos = self._selectionFrom + else: + pre = text.substring(to=self._cursorPos) + if self._replace: + post = text.substring(fr=self._cursorPos+1) + else: + post = text.substring(fr=self._cursorPos) + + text = pre + txt + post + if self._inputType & TTkK.Input_Number and \ + not text.lstrip('-').isdigit(): + return True + self.setText(text, self._cursorPos+txt.termWidth()) + + self._pushCursor() + self.textEdited.emit(self._text) + return True + def keyEvent(self, evt): baseText = self._text if evt.type == TTkK.SpecialKey: diff --git a/TermTk/TTkWidgets/texedit.py b/TermTk/TTkWidgets/texedit.py index a329221a..63e45183 100644 --- a/TermTk/TTkWidgets/texedit.py +++ b/TermTk/TTkWidgets/texedit.py @@ -289,9 +289,7 @@ class TTkTextEditView(TTkAbstractScrollView): @pyTTkSlot() def paste(self): txt = self._clipboard.text() - if not self._multiLine: - txt = TTkString().join(txt.split('\n')) - self._textCursor.insertText(txt) + self.pasteEvent(txt) @pyTTkSlot() def _documentChanged(self): @@ -421,6 +419,23 @@ class TTkTextEditView(TTkAbstractScrollView): self.update() return True + def pasteEvent(self, txt:str): + txt = TTkString(txt) + if not self._multiLine: + txt = TTkString().join(txt.split('\n')) + if self._replace: + self._textCursor.replaceText(txt, moveCursor=True) + else: + self._textCursor.insertText(txt, moveCursor=True) + # Scroll to align to the cursor + p = self._textCursor.position() + cx, cy = self._textWrap.dataToScreenPosition(p.line, p.pos) + self._updateSize() + self._scrolToInclude(cx,cy) + self._pushCursor() + self.update() + return True + def keyEvent(self, evt): if self._readOnly: return super().keyEvent(evt) @@ -523,8 +538,7 @@ class TTkTextEditView(TTkAbstractScrollView): self._textCursor.removeSelectedText() elif evt.key == TTkK.Key_Enter: if self._multiLine: - self._textCursor.insertText('\n') - self._textCursor.movePosition(TTkTextCursor.Right) + self._textCursor.insertText('\n', moveCursor=True) # Scroll to align to the cursor p = self._textCursor.position() cx, cy = self._textWrap.dataToScreenPosition(p.line, p.pos) @@ -535,10 +549,9 @@ class TTkTextEditView(TTkAbstractScrollView): return True else: # Input char if self._replace: - self._textCursor.replaceText(evt.key) + self._textCursor.replaceText(evt.key, moveCursor=True) else: - self._textCursor.insertText(evt.key) - self._textCursor.movePosition(TTkTextCursor.Right) + self._textCursor.insertText(evt.key, moveCursor=True) # Scroll to align to the cursor p = self._textCursor.position() cx, cy = self._textWrap.dataToScreenPosition(p.line, p.pos) diff --git a/TermTk/TTkWidgets/widget.py b/TermTk/TTkWidgets/widget.py index f3ecfdb2..ae4b0ea6 100644 --- a/TermTk/TTkWidgets/widget.py +++ b/TermTk/TTkWidgets/widget.py @@ -395,6 +395,9 @@ class TTkWidget(TMouseEvents,TKeyEvents, TDragEvents): return True return False + def pasteEvent(self, txt:str): + return False + _mouseOver = None _mouseOverTmp = None _mouseOverProcessed = False diff --git a/tests/test.input.raw.py b/tests/test.input.raw.py index 19d3589f..9584f067 100755 --- a/tests/test.input.raw.py +++ b/tests/test.input.raw.py @@ -42,6 +42,7 @@ def reset(): TTkTerm.push("\033[?1002l") TTkTerm.push("\033[?1015l") TTkTerm.push("\033[?1006l") + TTkTerm.push("\033[?1049l") # Switch to normal screen TTkTerm.push("\033[?2004l") # Paste Bracketed mode reset() @@ -51,6 +52,8 @@ TTkTerm.push("\033[?2004h") # Paste Bracketed mode # TTkTerm.push("\033[?1002h") # TTkTerm.push("\033[?1006h") # TTkTerm.push("\033[?1015h") +TTkTerm.push("\033[?1049h") # Switch to alternate screen + # TTkTerm.push(TTkTerm.Mouse.ON) # TTkTerm.push(TTkTerm.Mouse.DIRECT_ON) TTkTerm.setEcho(False) diff --git a/tests/test.input.win.py b/tests/test.input.win.py new file mode 100755 index 00000000..5e835751 --- /dev/null +++ b/tests/test.input.win.py @@ -0,0 +1,57 @@ +#!/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 sys, os +import logging + +sys.path.append(os.path.join(sys.path[0],'..')) +from TermTk import TTkLog, TTkK, TTkGridLayout, TTk, TTkLogViewer, TTkHelper + +def keyCallback(kevt=None, mevt=None): + if mevt is not None: + TTkLog.info(f"Mouse Event: {mevt}") + if kevt is not None: + if kevt.type == TTkK.Character: + TTkLog.info(f"Key Event: char '{kevt.key}' {kevt}") + else: + TTkLog.info(f"Key Event: Special '{kevt}'") + if kevt.key == "q": + input.close() + return False + return True + +def pasteCallback(txt:str): + TTkLog.info(f"PASTE:") + for s in txt.split('\n'): + TTkLog.info(f" | {s}") + return True + +root = TTk(layout=TTkGridLayout()) + +TTkLogViewer(parent=root) + +TTkHelper._rootWidget._input.inputEvent.connect(keyCallback) +TTkHelper._rootWidget._input.pasteEvent.connect(pasteCallback) + +root.mainloop()